Compare commits

..
7 Commits
Author SHA1 Message Date
François-X. T. 5d1ddeda24 Fmt 2026-05-09 17:09:10 -04:00
ProspectorandGitHub a0228dc2b1 Merge branch 'main' into patch-1 2026-05-09 14:01:54 -07:00
Corsican FrogandGitHub 3c6fadf5e8 Merge branch 'main' into patch-1 2026-05-07 15:54:36 +02:00
Corsican FrogandGitHub 8af43e59b9 Merge branch 'main' into patch-1 2026-05-07 11:24:07 +02:00
Corsican FrogandGitHub 418e4f281d Merge branch 'main' into patch-1 2026-05-06 18:46:07 +02:00
Corsican FrogandGitHub abc6cf59d8 Whitelist scannable instance content files
Only scan supported content archives into instance content.

Accept .jar files for mods and .zip files for datapacks, resourcepacks, and shaderpacks, after trimming the .disabled suffix. 

This prevents .DS_Store and other unsupported files from appearing in the Content tab.

Signed-off-by: Corsican Frog <49497194+acorsicanfrog@users.noreply.github.com>
2026-05-05 20:56:54 +02:00
Corsican FrogandGitHub 849fe4e1ba Hide dotfiles from instance content scanning
Prevent hidden files such as .DS_Store from being treated as valid instance content.

This updates the profile scanning logic in [packages/app-lib/src/state/profiles.rs](/Users/froggy/Downloads/code-main/packages/app-lib/src/state/profiles.rs#L420) to ignore basenames that start with '.', and applies that filter consistently in both scan paths.

Signed-off-by: Corsican Frog <49497194+acorsicanfrog@users.noreply.github.com>
2026-05-05 13:35:01 +02:00
2132 changed files with 57148 additions and 295085 deletions
@@ -9,6 +9,8 @@ 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/error.vue`
- Rich-text link tags: see `apps/frontend/src/pages/auth/welcome.vue` and `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>`.
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
BUMP_TYPE: minor
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -47,7 +47,7 @@ jobs:
- name: Setup Node
if: steps.changes.outputs.changed == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc
registry-url: https://registry.npmjs.org
@@ -63,7 +63,7 @@ jobs:
- name: Restore pnpm cache
if: steps.changes.outputs.changed == 'true'
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.store-path }}
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@d07a454dad7609a92316b57b23c9ccfd4f59af66 # 0.13.1
uses: styfle/cancel-workflow-action@85880fa0301c86cca9da44039ee3bb12d3bedbfa # 0.12.1
with:
workflow_id: all
access_token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Post or update changelog comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
with:
github-token: ${{ secrets.CROWDIN_GH_TOKEN }}
script: |
+4 -4
View File
@@ -12,15 +12,15 @@ jobs:
typos:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: crate-ci/typos@6ac2ebd1b93eade61faf7e12688ad87a073fea59 # v1.46.0
# see <https://github.com/influxdata/datafusion-udf-wasm/pull/275>
tombi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: taiki-e/install-action@b5fddbb5361bce8a06fb168c9d403a6cc552b084 # v2.75.29
with:
tool: tombi
- run: tombi lint
+3 -4
View File
@@ -6,15 +6,14 @@ on:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
SQLX_OFFLINE: true
jobs:
shear:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
- uses: cargo-bins/cargo-binstall@ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4 # v1.21.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
- run: cargo binstall --no-confirm cargo-shear
- run: cargo shear
+38 -10
View File
@@ -35,7 +35,7 @@ jobs:
if: ${{ always() }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check if workflow runs on an internal branch
id: check-internal
@@ -72,21 +72,49 @@ jobs:
fi
docker:
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-medium-amd64' || 'ubuntu-latest' }}
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
env:
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
with:
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:
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
with:
key: ${{ github.repository }}-daedalus-sccache
path: /mnt/sccache
- name: Setup sccache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Build daedalus_client
run: cargo build --release --package daedalus_client
@@ -97,11 +125,11 @@ jobs:
cp target/release/daedalus_client apps/daedalus_client/docker-stage/daedalus_client
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Generate Docker image metadata
id: docker-meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
env:
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
with:
@@ -116,14 +144,14 @@ jobs:
org.opencontainers.image.licenses=MIT
- name: Login to GitHub Packages
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: ./apps/daedalus_client/docker-stage
file: ./apps/daedalus_client/Dockerfile
+2 -2
View File
@@ -12,10 +12,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Log in to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+47 -113
View File
@@ -16,68 +16,46 @@ on:
workflow_dispatch:
workflow_call:
inputs:
preview:
required: false
type: boolean
description: 'Whether this is a preview deployment'
head_sha:
required: false
environment:
required: true
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)'
branch_alias:
required: false
type: string
description: 'The persistent preview alias for the branch'
description: 'The environment to deploy to (staging-preview or production-preview)'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.preview && 'preview' || 'push' }}
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.environment || 'push' }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
jobs:
deploy:
runs-on: namespace-profile-modrinth-frontend
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: read
deployments: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Configure environment
id: meta
env:
PREVIEW_INPUT: ${{ inputs.preview }}
REF: ${{ github.ref }}
DEPLOY_SHA: ${{ inputs.head_sha || github.sha }}
BRANCH_ALIAS: ${{ inputs.branch_alias }}
run: |
echo "cmd=deploy" >> $GITHUB_OUTPUT
SHA_SHORT="${DEPLOY_SHA::8}"
ENV_INPUT="${{ inputs.environment }}"
REF="${{ github.ref }}"
SHA_SHORT="${GITHUB_SHA::8}"
if [ "$PREVIEW_INPUT" = "true" ]; then
URL="https://git-$SHA_SHORT-frontend.modrinth.workers.dev"
STAGING_URL="https://git-$SHA_SHORT-staging-frontend.modrinth.workers.dev"
if [ "$ENV_INPUT" = "staging-preview" ]; then
echo "env=staging" >> $GITHUB_OUTPUT
echo "url=https://git-$SHA_SHORT-frontend-staging.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true" >> $GITHUB_OUTPUT
elif [ "$ENV_INPUT" = "production-preview" ]; then
echo "env=production" >> $GITHUB_OUTPUT
echo "url=$URL" >> $GITHUB_OUTPUT
echo "staging_url=$STAGING_URL" >> $GITHUB_OUTPUT
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true --var CF_PAGES_URL:$URL" >> $GITHUB_OUTPUT
STAGING_VARS=$(jq -r '.env.staging.vars | to_entries[] | "--var \(.key):\(.value)"' ./apps/frontend/wrangler.jsonc | tr '\n' ' ')
echo "staging_cmd=versions upload --preview-alias git-$SHA_SHORT-staging ${STAGING_VARS}--var PREVIEW:true --var CF_PAGES_URL:$STAGING_URL" >> $GITHUB_OUTPUT
if [ -n "$BRANCH_ALIAS" ]; then
echo "branch_alias_url=https://$BRANCH_ALIAS-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "staging_branch_alias_url=https://$BRANCH_ALIAS-staging-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "staging_branch_alias_cmd=versions upload --preview-alias $BRANCH_ALIAS-staging ${STAGING_VARS}--var PREVIEW:true --var CF_PAGES_URL:https://$BRANCH_ALIAS-staging-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
fi
echo "url=https://git-$SHA_SHORT-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true" >> $GITHUB_OUTPUT
elif [ "$REF" = "refs/heads/main" ]; then
echo "env=staging" >> $GITHUB_OUTPUT
@@ -90,23 +68,24 @@ jobs:
fi
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Set up caches
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
cache: |
rust
pnpm
apt
- name: Get pnpm store path
id: pnpm-store
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Set up Turborepo caching
uses: namespace-actions/setup-turbocache@f96bd6befa13ae74b2858a5be49573640d4636f5 # v0.0.2
- name: Restore pnpm cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.store-path }}
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-cache-
- name: Inject build variables
working-directory: ./apps/frontend
@@ -127,15 +106,15 @@ jobs:
working-directory: ./apps/frontend
run: pnpm build
env:
CF_PAGES_BRANCH: ${{ inputs.head_ref || github.ref_name }}
CF_PAGES_COMMIT_SHA: ${{ inputs.head_sha || github.sha }}
CF_PAGES_BRANCH: ${{ github.ref_name }}
CF_PAGES_COMMIT_SHA: ${{ github.sha }}
CF_PAGES_URL: ${{ steps.meta.outputs.url }}
BUILD_ENV: ${{ steps.meta.outputs.env }}
PREVIEW: ${{ inputs.preview && 'true' || 'false' }}
PREVIEW: ${{ inputs.environment != '' && 'true' || 'false' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Create Sentry release and upload sourcemaps
uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3.7.0
uses: getsentry/action-release@5657c9e888b4e2cc85f4d29143ea4131fde4a73a # v3.6.0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: modrinth
@@ -145,53 +124,17 @@ jobs:
sourcemaps: ./apps/frontend/.output/server
url_prefix: '~/'
- name: Install Wrangler CLI
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
- name: Deploy Cloudflare Worker
id: wrangler
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
environment: ${{ steps.meta.outputs.env != 'production' && steps.meta.outputs.env || '' }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.112.0'
command: --version
- parallel:
- name: Deploy Cloudflare Worker
id: wrangler
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
environment: ${{ steps.meta.outputs.env != 'production' && steps.meta.outputs.env || '' }}
workingDirectory: ./apps/frontend
command: ${{ steps.meta.outputs.cmd }}
- name: Deploy staging Cloudflare Worker preview
if: ${{ inputs.preview }}
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
command: ${{ steps.meta.outputs.staging_cmd }}
- name: Deploy persistent Cloudflare Worker preview
if: ${{ inputs.preview && inputs.branch_alias != '' }}
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
command: versions upload --preview-alias ${{ inputs.branch_alias }} --var PREVIEW:true --var CF_PAGES_URL:${{ steps.meta.outputs.branch_alias_url }}
- name: Deploy persistent staging Cloudflare Worker preview
if: ${{ inputs.preview && inputs.branch_alias != '' }}
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
command: ${{ steps.meta.outputs.staging_branch_alias_cmd }}
wranglerVersion: '4.54.0'
command: ${{ steps.meta.outputs.cmd }}
- name: Purge cache
if: github.ref == 'refs/heads/prod'
@@ -202,23 +145,14 @@ jobs:
--data '{"hosts": ["modrinth.com", "www.modrinth.com", "staging.modrinth.com"]}' \
https://api.cloudflare.com/client/v4/zones/e39df17b9c4ef44cbce2646346ee6d33/purge_cache
- name: Write deployment URLs to files
if: ${{ inputs.preview }}
- name: Write deployment URL to file
if: ${{ inputs.environment != '' }}
run: |
echo "${{ steps.meta.outputs.url }}" > deployment-url-production.txt
echo "${{ steps.meta.outputs.staging_url }}" > deployment-url-staging.txt
echo "${{ steps.meta.outputs.url }}" > deployment-url-${{ inputs.environment }}.txt
- parallel:
- name: Upload production deployment URL
if: ${{ inputs.preview }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deployment-url-production
path: deployment-url-production.txt
- name: Upload staging deployment URL
if: ${{ inputs.preview }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deployment-url-staging
path: deployment-url-staging.txt
- name: Upload deployment URL
if: ${{ inputs.environment != '' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deployment-url-${{ inputs.environment }}
path: deployment-url-${{ inputs.environment }}.txt
+36 -94
View File
@@ -12,34 +12,22 @@ on:
- '.github/workflows/frontend-preview.yml'
jobs:
metadata:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
outputs:
branch-alias: ${{ steps.meta.outputs.branch_alias }}
steps:
- name: Configure branch alias
id: meta
run: echo "branch_alias=pr-${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
deploy:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
needs: metadata
uses: ./.github/workflows/frontend-deploy.yml
secrets: inherit
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-deploy
group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.environment }}
cancel-in-progress: true
strategy:
matrix:
environment: [staging-preview, production-preview]
with:
preview: true
head_sha: ${{ github.event.pull_request.head.sha }}
head_ref: ${{ github.event.pull_request.head.ref }}
branch_alias: ${{ needs.metadata.outputs.branch-alias }}
environment: ${{ matrix.environment }}
deploy-storybook:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
needs: metadata
runs-on: namespace-profile-modrinth-frontend
runs-on: blacksmith-2vcpu-ubuntu-2404
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-storybook
cancel-in-progress: true
@@ -48,21 +36,27 @@ jobs:
deployments: write
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Set up caches
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
- name: Get pnpm store path
id: pnpm-store
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Restore pnpm cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
cache: |
pnpm
path: ${{ steps.pnpm-store.outputs.store-path }}
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-cache-
- name: Install dependencies
working-directory: ./packages/ui
@@ -70,53 +64,26 @@ jobs:
- name: Build Storybook
working-directory: ./packages/ui
env:
NODE_OPTIONS: --max-old-space-size=6144
run: pnpm run build-storybook
- name: Configure short SHA
id: meta
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: echo "sha_short=${HEAD_SHA::8}" >> $GITHUB_OUTPUT
run: echo "sha_short=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
- name: Install Wrangler CLI
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
- name: Deploy Storybook preview
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./packages/ui
packageManager: pnpm
wranglerVersion: '4.112.0'
command: --version
- parallel:
- name: Deploy Storybook preview
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./packages/ui
command: versions upload --preview-alias git-${{ steps.meta.outputs.sha_short }}
- name: Deploy persistent Storybook preview
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
env:
NODE_OPTIONS: --max-old-space-size=6144
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./packages/ui
command: versions upload --preview-alias ${{ needs.metadata.outputs.branch-alias }}
wranglerVersion: '4.54.0'
command: versions upload --preview-alias git-${{ steps.meta.outputs.sha_short }}
comment:
if: |
!cancelled()
&& github.repository_owner == 'modrinth'
&& github.event.pull_request.head.repo.full_name == github.repository
&& needs.deploy.result == 'success'
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
needs: [metadata, deploy, deploy-storybook]
needs: [deploy, deploy-storybook]
steps:
- name: Download deployment URLs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -126,31 +93,16 @@ jobs:
- name: Read deployment URLs
id: urls
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BRANCH_ALIAS: ${{ needs.metadata.outputs.branch-alias }}
STORYBOOK_RESULT: ${{ needs.deploy-storybook.result }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
STAGING_SHA_URL=$(cat deployment-url-staging.txt)
PRODUCTION_SHA_URL=$(cat deployment-url-production.txt)
STAGING_BRANCH_URL="https://$BRANCH_ALIAS-staging-frontend.modrinth.workers.dev"
PRODUCTION_BRANCH_URL="https://$BRANCH_ALIAS-frontend.modrinth.workers.dev"
STAGING_PREVIEW_URL=$(cat deployment-url-staging-preview.txt)
PRODUCTION_PREVIEW_URL=$(cat deployment-url-production-preview.txt)
if [ "$STORYBOOK_RESULT" = "success" ]; then
STORYBOOK_SHA_URL="https://git-${HEAD_SHA::8}-storybook.modrinth.workers.dev"
STORYBOOK_BRANCH_URL="https://$BRANCH_ALIAS-storybook.modrinth.workers.dev"
else
STORYBOOK_SHA_URL="⚠️ Deployment $STORYBOOK_RESULT ([logs]($RUN_URL))"
STORYBOOK_BRANCH_URL="⚠️ Deployment $STORYBOOK_RESULT ([logs]($RUN_URL))"
fi
echo "Production preview URL: $PRODUCTION_PREVIEW_URL"
echo "Staging preview URL: $STAGING_PREVIEW_URL"
echo "staging-sha-url=$STAGING_SHA_URL" >> $GITHUB_OUTPUT
echo "production-sha-url=$PRODUCTION_SHA_URL" >> $GITHUB_OUTPUT
echo "storybook-sha-url=$STORYBOOK_SHA_URL" >> $GITHUB_OUTPUT
echo "staging-branch-url=$STAGING_BRANCH_URL" >> $GITHUB_OUTPUT
echo "production-branch-url=$PRODUCTION_BRANCH_URL" >> $GITHUB_OUTPUT
echo "storybook-branch-url=$STORYBOOK_BRANCH_URL" >> $GITHUB_OUTPUT
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
- name: Find comment
if: github.event_name == 'pull_request'
@@ -171,21 +123,11 @@ jobs:
body: |
## Frontend previews
Last deployed commit is [${{ github.event.pull_request.head.sha }}](${{ github.event.pull_request.head.repo.html_url }}/commit/${{ github.event.pull_request.head.sha }})
### Persistent
Last deployed commit is [${{ github.sha }}](${{ github.event.pull_request.head.repo.html_url }}/commit/${{ github.sha }})
| Environment | URL |
|-------------|-----|
| staging | ${{ steps.urls.outputs.staging-branch-url }} |
| production | ${{ steps.urls.outputs.production-branch-url }} |
| storybook | ${{ steps.urls.outputs.storybook-branch-url }} |
### Latest commit
| Environment | URL |
|-------------|-----|
| staging | ${{ steps.urls.outputs.staging-sha-url }} |
| production | ${{ steps.urls.outputs.production-sha-url }} |
| storybook | ${{ steps.urls.outputs.storybook-sha-url }} |
| staging | ${{ steps.urls.outputs.staging-preview-url }} |
| production | ${{ steps.urls.outputs.production-preview-url }} |
| storybook | ${{ steps.urls.outputs.storybook-preview-url }} |
edit-mode: replace
+2 -16
View File
@@ -51,22 +51,11 @@ jobs:
CROWDIN_GH_TOKEN_DEFINED: ${{ secrets.CROWDIN_GH_TOKEN != '' }}
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.ref }}
token: ${{ secrets.CROWDIN_GH_TOKEN }}
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.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
@@ -90,7 +79,7 @@ jobs:
echo "safe_branch_name=$SAFE_BRANCH_NAME" >> "$GITHUB_OUTPUT"
- name: Download translations from Crowdin
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with:
upload_sources: false
upload_translations: false
@@ -106,9 +95,6 @@ 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:
+3 -24
View File
@@ -4,14 +4,11 @@ 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,21 +53,9 @@ jobs:
CROWDIN_PERSONAL_TOKEN_DEFINED: ${{ secrets.CROWDIN_PERSONAL_TOKEN != '' }}
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.ref }}
fetch-depth: 2
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.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
@@ -83,7 +68,7 @@ jobs:
echo "safe_branch_name=$SAFE_BRANCH_NAME" >> "$GITHUB_OUTPUT"
- name: Upload translations to Crowdin
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with:
upload_sources: true
upload_translations: false
@@ -94,9 +79,3 @@ 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 }}
+33 -24
View File
@@ -33,7 +33,7 @@ jobs:
if: ${{ always() }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check if workflow runs on an internal branch
id: check-internal
@@ -70,21 +70,28 @@ jobs:
fi
docker:
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-labrinth' || 'ubuntu-latest' }}
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
env:
SQLX_OFFLINE: 'true'
GIT_HASH: ${{ github.sha }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '20G' || '' }}
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
RUST_MIN_STACK: '16777216'
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
with:
rustflags: ''
cache: false
@@ -92,23 +99,25 @@ jobs:
- name: Setup mold
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Setup sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Set up caches
if: needs.skip-if-clean.outputs.internal == 'true'
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
- name: Cache Cargo registry and index
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
cache: |
rust
apt
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Configure sccache
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
with:
key: ${{ github.repository }}-labrinth-sccache
path: /mnt/sccache
- name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake libcurl4-openssl-dev
- name: Setup sccache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Build labrinth
run: cargo build --profile release-labrinth --package labrinth
@@ -121,11 +130,11 @@ jobs:
cp -r apps/labrinth/assets apps/labrinth/docker-stage/assets
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Generate Docker image metadata
id: docker-meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
env:
# GitHub Packages requires annotations metadata in at least the index descriptor to show them
# up properly in its UI it seems, but it's not clear about it, because the docs refer to the
@@ -144,18 +153,18 @@ jobs:
org.opencontainers.image.licenses=AGPL-3.0-only
- name: Login to GitHub Packages
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: ./apps/labrinth/docker-stage
file: ./apps/labrinth/Dockerfile
push: true
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}
annotations: ${{ steps.docker-meta.outputs.annotations }}
+41
View File
@@ -0,0 +1,41 @@
name: Prepare pnpm cache
on:
push:
paths:
- .github/workflows/prepare-pnpm-cache.yml
- package.json
- pnpm-lock.yaml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
jobs:
prepare:
if: github.repository_owner == 'modrinth'
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store path
id: pnpm-store
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.store-path }}
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm recursive install --frozen-lockfile
+32 -48
View File
@@ -30,10 +30,6 @@ 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 }}
@@ -44,64 +40,57 @@ jobs:
name: Build
env:
VITE_STRIPE_PUBLISHABLE_KEY: pk_live_51JbFxJJygY5LJFfKLVVldb10HlLt24p421OWRsTOWc5sXYFOnFUXWieSc6HD3PHo25ktx8db1WcHr36XGFvZFVUz00V9ixrCs5
# SCCACHE_DIR: '/mnt/sccache'
# SCCACHE_CACHE_SIZE: '10G'
# SCCACHE_MULTILEVEL_CHAIN: 'disk,s3'
SCCACHE_S3_KEY_PREFIX: '${{ github.repository }}/'
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: 'sccache'
strategy:
fail-fast: false
matrix:
platform:
[
namespace-profile-medium-amd64-macos,
namespace-profile-medium-amd64-windows,
namespace-profile-medium-amd64,
platform: [
blacksmith-6vcpu-macos-26,
blacksmith-8vcpu-windows-2025, # At time of writing, Windows 4 vCPU VMs don't seem to actually exist
blacksmith-4vcpu-ubuntu-2404,
]
include:
- platform: namespace-profile-medium-amd64-macos
- platform: blacksmith-6vcpu-macos-26
artifact-target-name: universal-apple-darwin
- platform: namespace-profile-medium-amd64-windows
- platform: blacksmith-8vcpu-windows-2025
artifact-target-name: x86_64-pc-windows-msvc
- platform: namespace-profile-medium-amd64
- platform: blacksmith-4vcpu-ubuntu-2404
artifact-target-name: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.platform }}
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
with:
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
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Set up caches
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'macos')
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
cache: |
rust
pnpm
- name: Configure sccache
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'macos')
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
node-version-file: .nvmrc
cache: 'pnpm'
- name: Generate tauri-dev.conf.json
shell: bash
@@ -120,15 +109,10 @@ jobs:
- name: Install Linux build dependencies
if: contains(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
- name: Setup Java
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
with:
distribution: temurin
java-version: 17
packages: libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
version: v1 # cache key
- name: Setup Dasel
uses: jaxxstorm/action-install-gh-release@25e24d2d23ae098373794ef1d6faecb48ee52da8 # v3.0.0
@@ -142,10 +126,7 @@ jobs:
- name: Set application version and environment
shell: bash
run: |
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
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
BUILD_ENVIRONMENT="${{ inputs.environment || 'prod' }}"
echo "Setting application version to $APP_VERSION"
echo "Using environment $BUILD_ENVIRONMENT"
@@ -155,6 +136,9 @@ jobs:
cp "packages/app-lib/.env.${BUILD_ENVIRONMENT}" packages/app-lib/.env
- name: Setup Turbo cache
uses: rharkor/caching-for-turbo@56219402aacc0d06b650d898c222996dbc1191ec # v2.3.14
- name: Install dependencies
run: pnpm install
@@ -169,7 +153,7 @@ jobs:
fi
- name: Build macOS app
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' }}
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' }}
if: contains(matrix.platform, 'macos')
env:
TAURI_BUNDLER_DMG_IGNORE_CI: 'true'
@@ -184,7 +168,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') || 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' }}
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' }}
if: contains(matrix.platform, 'ubuntu')
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
@@ -195,7 +179,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') || 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"' }}
${{ (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"' }}
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
if: contains(matrix.platform, 'windows')
env:
+3 -6
View File
@@ -15,7 +15,7 @@ jobs:
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: namespace-profile-medium-amd64
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
VERSION_TAG: ${{ github.event.workflow_run.head_branch }}
LINUX_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-unknown-linux-gnu)
@@ -25,7 +25,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify ref is a tag
env:
@@ -57,11 +57,8 @@ jobs:
- name: Extract app changelog
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ env.VERSION_TAG }}
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
run: npx --yes tsx scripts/build-theseus-release-notes.ts
- name: Generate version manifest
run: |
+61 -35
View File
@@ -22,7 +22,7 @@ jobs:
if: ${{ always() }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check if workflow runs on an internal branch
id: check-internal
@@ -62,7 +62,7 @@ jobs:
build:
name: Lint and Test
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-large-amd64' || 'ubuntu-latest' }}
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
@@ -76,71 +76,98 @@ jobs:
# since we don't want warnings to become errors
# while developing)
RUSTFLAGS: -Dwarnings
# Redis config for tests
REDIS_TOPOLOGY: cluster
REDIS_CONNECTION_TYPE: multiplexed
REDIS_URL: redis://127.0.0.1:7000,redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003,redis://127.0.0.1:7004,redis://127.0.0.1:7005
# Avoid stack overflows in tests
RUST_MIN_STACK: 33554432
# sccache config (only populated for internal branches; secrets aren't
# available to forked PRs, and blacksmith stickydisk requires a
# blacksmith runner)
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2
- 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
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Set up caches
if: needs.skip-if-clean.outputs.internal == 'true'
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
- name: Get pnpm store path
id: pnpm-store
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Restore pnpm cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
cache: |
rust
pnpm
apt
- name: Set up Turborepo caching
if: needs.skip-if-clean.outputs.internal == 'true'
uses: namespace-actions/setup-turbocache@f96bd6befa13ae74b2858a5be49573640d4636f5 # v0.0.2
- name: Install apt dependencies
run: |
sudo apt-get update
sudo apt-get install -y cmake libcurl4-openssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
path: ${{ steps.pnpm-store.outputs.store-path }}
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-cache-
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
with:
rustflags: ''
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:
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
with:
key: ${{ github.repository }}-turbo-sccache
path: /mnt/sccache
- name: Setup sccache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Setup binstall
uses: cargo-bins/cargo-binstall@ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4 # v1.21.0
uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
- name: Setup nextest
run: cargo binstall --no-confirm --secure --locked cargo-nextest@0.9.133
run: cargo binstall --no-confirm --secure 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
- name: Setup cargo-sqlx
uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7
uses: taiki-e/cache-cargo-install-action@f9eed3e4680f27610dc6d8c67be1b88593f7dade # v3.0.6
with:
tool: sqlx-cli@0.8.6
locked: false
no-default-features: true
features: rustls,postgres
- name: Setup Turbo cache
uses: rharkor/caching-for-turbo@56219402aacc0d06b650d898c222996dbc1191ec # v2.3.14
- name: Install dependencies
run: pnpm install
@@ -162,7 +189,7 @@ jobs:
- name: Start services
if: steps.check-labrinth.outputs.needs_services == 'true'
run: docker compose --profile clustered-redis up --wait
run: docker compose up --wait
- name: Setup labrinth environment and database
if: steps.check-labrinth.outputs.needs_services == 'true'
@@ -177,5 +204,4 @@ jobs:
- name: Verify intl:extract has been run
run: |
pnpm turbo run intl:extract --force
pnpm scripts i18n-icu-contract prune-local --check
git diff --exit-code --color */*/src/locales/*/index.json
git diff --exit-code --color */*/src/locales/en-US/index.json
-1
View File
@@ -22,7 +22,6 @@ node_modules
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/i18n-ally-custom-framework.yml
# IDE - IntelliJ
.idea/*
+1 -1
View File
@@ -1,3 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode", "Vue.volar", "rust-lang.rust-analyzer", "lokalise.i18n-ally"]
"recommendations": ["esbenp.prettier-vscode", "Vue.volar", "rust-lang.rust-analyzer"]
}
-10
View File
@@ -1,10 +0,0 @@
languageIds:
- vue
- typescript
- javascript
- typescriptreact
usageMatchRegex:
- id:\s*['"]({key})['"]
monopoly: true
+2 -12
View File
@@ -9,7 +9,7 @@
"files.insertFinalNewline": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
"source.organizeImports": "always"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[vue]": {
@@ -31,15 +31,5 @@
"editor.defaultFormatter": "rust-lang.rust-analyzer"
},
"css.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
"scss.lint.unknownAtRules": "ignore"
}
+3 -3
View File
@@ -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/AGENTS.md`
- **Labrinth (backend):** See `apps/labrinth/CLAUDE.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 file with detailed instructions:
Each project may have its own `CLAUDE.md` with detailed instructions:
- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API
- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
## Code Guidelines
Generated
+185 -899
View File
File diff suppressed because it is too large Load Diff
+7 -28
View File
@@ -9,13 +9,10 @@ members = [
"packages/ariadne",
"packages/daedalus",
"packages/labrinth-derive",
"packages/modrinth-content-management",
"packages/modrinth-log",
"packages/modrinth-maxmind",
"packages/modrinth-util",
"packages/neverbounce",
"packages/path-util",
"packages/xredis",
]
[workspace.package]
@@ -35,6 +32,7 @@ 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 = [
@@ -53,7 +51,6 @@ aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
] }
base64 = "0.22.1"
bitflags = "2.9.4"
bon = "3.9.3"
bytemuck = "1.24.0"
bytes = "1.10.1"
censor = "0.3.0"
@@ -67,13 +64,11 @@ 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"
data-url = "0.3.2"
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "c7209b086829572f368f1194aba54bdea8dc540b", version = "0.23.0" }
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "db5fb00b036ecc8fe5f18853c559b745ffe47bde", version = "0.22.1" }
derive_more = "2.1.1"
directories = "6.0.0"
dirs = "6.0.0"
@@ -82,20 +77,18 @@ 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"
governor = "0.10.4"
heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
httpdate = "1.0.3"
hyper = "1.7.0"
hyper-rustls = { version = "0.27.7", default-features = false, features = [
"aws-lc-rs",
@@ -122,31 +115,21 @@ lettre = { version = "0.11.19", default-features = false, features = [
"tokio1",
"tokio1-rustls",
] }
lz4_flex = { version = "0.11.5", default-features = false, features = [
"checked-decode",
"safe-decode",
"safe-encode",
"std",
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.30.0", default-features = false }
modrinth-content-management = { path = "packages/modrinth-content-management" }
modrinth-log = { path = "packages/modrinth-log" }
modrinth-util = { path = "packages/modrinth-util" }
muralpay = { path = "packages/muralpay" }
murmur2 = "0.1.0"
native-dialog = "0.9.2"
neverbounce = { path = "packages/neverbounce" }
notify = { version = "8.2.0", default-features = false }
notify-debouncer-mini = { version = "0.7.0", default-features = false }
objc2-app-kit = { version = "0.3.2", default-features = false }
p256 = "0.13.2"
parking_lot = "0.12.5"
paste = "1.0.15"
path-util = { path = "packages/path-util" }
phf = { version = "0.13.1", features = ["macros"] }
png = "0.18.0"
postcard = { version = "1.1.3", default-features = false, features = ["alloc"] }
proc-macro2 = { version = "1.0" }
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
@@ -154,8 +137,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 = "1.4.1"
redis = "0.32.7"
regex = "1.12.2"
reqwest = { version = "0.12.24", default-features = false }
rgb = "0.8.52"
@@ -171,7 +153,6 @@ rust-s3 = { version = "0.37.0", default-features = false, features = [
] }
rustls = "0.23.32"
rusty-money = "0.4.1"
scalar_api_reference = { version = "0.2.2", default-features = false }
secrecy = "0.10.3"
sentry = { version = "0.45.0", default-features = false, features = [
"backtrace",
@@ -208,7 +189,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 = { git = "https://github.com/modrinth/plugins-workspace", rev = "0d30f2aa28ec668ce187d527da1c475da3c01cbc", default-features = false, features = [
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
"rustls-tls",
"zip",
] }
@@ -231,10 +212,10 @@ typed-path = "0.12.0"
url = "2.5.7"
urlencoding = "2.1.3"
utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "decimal"] }
utoipa-actix-web = { version = "0.1.2" }
utoipa-scalar = { version = "0.3.0", default-features = false }
uuid = "1.18.1"
validator = "0.20.0"
webauthn-rs = "0.5.5"
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"
@@ -242,7 +223,6 @@ windows = "=0.61.3" # Locked on 0.61 until we can update windows-core to 0.62
windows-core = "=0.61.2" # Locked on 0.61 until webview2-com updates to 0.62
winreg = "0.55.0"
woothee = "0.13.0"
xredis = { path = "packages/xredis" }
yaserde = "0.12.0"
zbus = "5.11.0"
zip = { version = "6.0.0", default-features = false, features = [
@@ -286,7 +266,6 @@ 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"
+2 -2
View File
@@ -3,13 +3,13 @@
![Issues](https://img.shields.io/github/issues-raw/Modrinth/code?color=c78aff&label=issues&style=for-the-badge)
![Pull Requests](https://img.shields.io/github/issues-pr-raw/Modrinth/code?color=c78aff&label=PRs&style=for-the-badge)
![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=c78aff&label=contributors&style=for-the-badge)
![Lines of Code](https://img.shields.io/endpoint?url=https://loctopus.creeperkatze.dev/github/modrinth/code/badge?style=flat&logoColor=white&color=c78aff&style=for-the-badge)
![Lines](https://img.shields.io/endpoint?url=https://ghloc.vercel.app/api/modrinth/code/badge?style=flat&logoColor=white&color=c78aff&style=for-the-badge)
![Commit Activity](https://img.shields.io/github/commit-activity/m/Modrinth/code?color=c78aff&label=commits&style=for-the-badge)
![Last Commit](https://img.shields.io/github/last-commit/Modrinth/code?color=c78aff&label=last%20commit&style=for-the-badge)
## Modrinth Monorepo
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains ![Lines of Code](https://img.shields.io/endpoint?url=https://loctopus.creeperkatze.dev/github/modrinth/code/badge%3Fformat%3Dhuman&logoColor=white&color=black&label=) lines of code and has ![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=black&label=) contributors!
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains ![Lines](https://img.shields.io/endpoint?url=https://ghloc.vercel.app/api/modrinth/olympus/badge?logoColor=white&color=black&label=) lines of code and has ![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=black&label=) contributors!
If you're not a developer and you've stumbled upon this repository, you can access the web interface on the [Modrinth website](https://modrinth.com) and download the latest release of the app [here](https://modrinth.com/app).
-3
View File
@@ -8,11 +8,8 @@ extend-exclude = [
"packages/blog/",
# contains licenses like `CC-BY-ND-4.0`
"packages/moderation/src/data/stages/license.ts",
"packages/moderation/src/utils.ts",
# contains payment card IDs like `IY1VMST1MOXS` which are flagged
"apps/labrinth/src/queue/payouts/mod.rs",
# contains domain names with deliberate typos
"apps/labrinth/assets/disposable_email_blocklist.txt"
]
[default.extend-words]
+3 -4
View File
@@ -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,8 +44,7 @@
"vue": "^3.5.13",
"vue-i18n": "^10.0.0",
"vue-router": "^4.6.0",
"vue-virtual-scroller": "v2.0.0-beta.8",
"vuedraggable": "^4.1.0"
"vue-virtual-scroller": "v2.0.0-beta.8"
},
"devDependencies": {
"@eslint/compat": "^1.1.1",
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

@@ -24,8 +24,7 @@ import { computed, ref } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { install_duplicate_instance } from '@/helpers/install'
import { remove } from '@/helpers/instance'
import { duplicate, remove } from '@/helpers/profile.js'
const { handleError } = injectNotificationManager()
@@ -49,23 +48,24 @@ const instanceComponents = ref(null)
const currentDeleteInstance = ref(null)
const confirmModal = ref(null)
async function deleteInstance() {
async function deleteProfile() {
if (currentDeleteInstance.value) {
instanceComponents.value = instanceComponents.value.filter(
(x) => x.instance.id !== currentDeleteInstance.value,
(x) => x.instance.path !== currentDeleteInstance.value,
)
await remove(currentDeleteInstance.value).catch(handleError)
}
}
async function duplicateInstance(p) {
await install_duplicate_instance(p).catch(handleError)
async function duplicateProfile(p) {
await duplicate(p).catch(handleError)
}
const handleRightClick = (event, instanceId) => {
const item = instanceComponents.value.find((x) => x.instance.id === instanceId)
const handleRightClick = (event, profilePathId) => {
const item = instanceComponents.value.find((x) => x.instance.path === profilePathId)
const baseOptions = [
...(item.instance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'add_content' },
{ type: 'divider' },
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open' },
@@ -89,14 +89,10 @@ const handleRightClick = (event, instanceId) => {
...baseOptions,
]
: [
...(item.instance.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
{
name: 'play',
color: 'primary',
},
...baseOptions,
],
)
@@ -118,16 +114,16 @@ const handleOptionsClick = async (args) => {
break
case 'duplicate':
if (args.item.instance.install_stage == 'installed')
await duplicateInstance(args.item.instance.id)
await duplicateProfile(args.item.instance.path)
break
case 'open':
await args.item.openFolder()
break
case 'copy':
await navigator.clipboard.writeText(args.item.instance.id)
await navigator.clipboard.writeText(args.item.instance.path)
break
case 'delete':
currentDeleteInstance.value = args.item.instance.id
currentDeleteInstance.value = args.item.instance.path
confirmModal.value.show()
break
}
@@ -325,13 +321,13 @@ const filteredResults = computed(() => {
<Instance
v-for="instance in instanceSection.value"
ref="instanceComponents"
:key="instance.id + instance.install_stage"
:key="instance.path + instance.install_stage"
:instance="instance"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.id)"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
/>
</section>
</Accordion>
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteInstance" />
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteProfile" />
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
+25 -27
View File
@@ -21,10 +21,9 @@ import Instance from '@/components/ui/Instance.vue'
import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { install_duplicate_instance } from '@/helpers/install'
import { kill, remove, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process.js'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { get_by_profile_path } from '@/helpers/process.js'
import { duplicate, kill, remove, run } from '@/helpers/profile.js'
import { showProfileInFolder } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
import { handleSevereError } from '@/store/error.js'
@@ -61,19 +60,20 @@ const deleteConfirmModal = ref(null)
const currentDeleteInstance = ref(null)
async function deleteInstance() {
async function deleteProfile() {
if (currentDeleteInstance.value) {
await remove(currentDeleteInstance.value).catch(handleError)
}
}
async function duplicateInstance(p) {
await install_duplicate_instance(p).catch(handleError)
async function duplicateProfile(p) {
await duplicate(p).catch(handleError)
}
const handleInstanceRightClick = async (event, passedInstance) => {
const baseOptions = [
...(passedInstance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'add_content' },
{ type: 'divider' },
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open_folder' },
@@ -85,7 +85,7 @@ const handleInstanceRightClick = async (event, passedInstance) => {
},
]
const runningProcesses = await get_by_instance_id(passedInstance.id).catch(handleError)
const runningProcesses = await get_by_profile_path(passedInstance.path).catch(handleError)
const options =
runningProcesses.length > 0
@@ -97,14 +97,10 @@ const handleInstanceRightClick = async (event, passedInstance) => {
...baseOptions,
]
: [
...(passedInstance.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
{
name: 'play',
color: 'primary',
},
...baseOptions,
]
@@ -130,14 +126,16 @@ const handleProjectClick = (event, passedInstance) => {
const handleOptionsClick = async (args) => {
switch (args.option) {
case 'play':
await run(args.item.id).catch((err) => handleSevereError(err, { instanceId: args.item.id }))
await run(args.item.path).catch((err) =>
handleSevereError(err, { profilePath: args.item.path }),
)
trackEvent('InstanceStart', {
loader: args.item.loader,
game_version: args.item.game_version,
})
break
case 'stop':
await kill(args.item.id).catch(handleError)
await kill(args.item.path).catch(handleError)
trackEvent('InstanceStop', {
loader: args.item.loader,
game_version: args.item.game_version,
@@ -146,26 +144,26 @@ const handleOptionsClick = async (args) => {
case 'add_content':
await router.push({
path: `/browse/${args.item.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: args.item.id },
query: { i: args.item.path },
})
break
case 'edit':
await router.push({
path: `/instance/${encodeURIComponent(args.item.id)}`,
path: `/instance/${encodeURIComponent(args.item.path)}`,
})
break
case 'duplicate':
if (args.item.install_stage == 'installed') await duplicateInstance(args.item.id)
if (args.item.install_stage == 'installed') await duplicateProfile(args.item.path)
break
case 'delete':
currentDeleteInstance.value = args.item.id
currentDeleteInstance.value = args.item.path
deleteConfirmModal.value.show()
break
case 'open_folder':
await showInstanceInFolder(args.item.id)
await showProfileInFolder(args.item.path)
break
case 'copy_path':
await navigator.clipboard.writeText(args.item.id)
await navigator.clipboard.writeText(args.item.path)
break
case 'install': {
await installVersion(
@@ -241,7 +239,7 @@ onUnmounted(() => {
</script>
<template>
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteInstance" />
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteProfile" />
<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">
@@ -258,7 +256,7 @@ onUnmounted(() => {
0,
row.compact ? maxInstancesPerCompactRow : maxInstancesPerRow,
)"
:key="row.label + instance.id"
:key="row.label + instance.path"
:instance="instance"
:compact="row.compact"
:first="instanceIndex === 0"
@@ -150,10 +150,7 @@ async function refreshValues() {
if (equippedSkin.value) {
try {
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
headUrlCache.value = new Map(headUrlCache.value).set(
equippedSkin.value.texture_key,
headUrl,
)
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
} catch (error) {
console.warn('Failed to get head render for equipped skin:', error)
}
@@ -163,24 +160,12 @@ 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/instance'
import { add_project_from_path } from '@/helpers/profile.js'
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.id, project.path ?? project).catch(handleError)
await add_project_from_path(props.instance.path, project.path ?? project).catch(handleError)
}
}
const handleSearchContent = async () => {
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
query: { i: props.instance.id },
query: { i: props.instance.path },
})
}
</script>
@@ -1,5 +1,5 @@
<template>
<div class="flex gap-2 items-center">
<div class="flex gap-4 items-center">
<ButtonStyled
v-if="hasActiveLoadingBars && !hasVisibleActiveDownloadToasts"
color="brand"
@@ -14,7 +14,6 @@
<UnplugIcon class="text-secondary" />
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
</div>
<AppUpdateButton />
<div
class="flex border-solid border-surface-5 text-sm items-center gap-2 py-1.5 px-3 rounded-xl border"
>
@@ -23,28 +22,28 @@
<div class="text-contrast flex items-center gap-2">
<router-link
v-tooltip="formatMessage(messages.viewInstance)"
:to="`/instance/${encodeURIComponent(selectedProcess.instance.id)}`"
:to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`"
class="hover:underline"
>
{{ selectedProcess.instance.name }}
{{ selectedProcess.profile.name }}
</router-link>
<Dropdown
v-if="currentProcesses.length > 1"
placement="bottom"
:triggers="['click']"
:hide-triggers="['click']"
@show="showInstances = true"
@hide="showInstances = false"
@show="showProfiles = true"
@hide="showProfiles = false"
>
<ButtonStyled type="transparent" circular size="small">
<button
v-tooltip="
showInstances
showProfiles
? formatMessage(messages.hideMoreRunningInstances)
: formatMessage(messages.showMoreRunningInstances)
"
>
<DropdownIcon :class="{ 'rotate-180': !!showInstances }" />
<DropdownIcon :class="{ 'rotate-180': !!showProfiles }" />
</button>
</ButtonStyled>
<template #popper>
@@ -69,7 +68,7 @@
>
<OnlineIndicatorIcon />
<span class="mr-auto text-contrast flex items-center gap-2">
{{ process.instance.name }}
{{ process.profile.name }}
<StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
</span>
</button>
@@ -83,7 +82,7 @@
<button
v-tooltip="formatMessage(messages.viewLogs)"
class="active:scale-95 flex"
@click.stop="goToTerminal(process.instance.id)"
@click.stop="goToTerminal(process.profile.path)"
>
<TerminalSquareIcon class="text-secondary size-5" />
</button>
@@ -134,17 +133,14 @@ 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 { useRouter } from 'vue-router'
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener, process_listener } from '@/helpers/events'
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'
@@ -155,12 +151,12 @@ const { formatMessage } = useVIntl()
const router = useRouter()
const showInstances = ref(false)
const showProfiles = ref(false)
interface RunningProcess {
uuid: string
instance_id: string
instance: GameInstance
profile_path: string
profile: GameInstance
}
const messages = defineMessages({
@@ -221,22 +217,22 @@ const refresh = async () => {
const processes = ((await getRunningProcesses().catch((error) => {
handleError(error)
return []
})) ?? []) as Array<{ uuid: string; instance_id: string }>
const instanceIds = processes.map((process) => process.instance_id)
const instances: GameInstance[] = await getInstances(instanceIds).catch((error) => {
})) ?? []) as Array<{ uuid: string; profile_path: string }>
const paths = processes.map((process) => process.profile_path)
const profiles: GameInstance[] = await getInstances(paths).catch((error) => {
handleError(error)
return []
})
currentProcesses.value = processes
.map((process) => {
const instance = instances.find((item) => process.instance_id === item.id)
if (!instance) {
const profile = profiles.find((item) => process.profile_path === item.path)
if (!profile) {
return null
}
return {
...process,
instance,
profile,
}
})
.filter((process): process is RunningProcess => process !== null)
@@ -269,8 +265,8 @@ const stop = async (process: RunningProcess) => {
await killProcess(process.uuid).catch(handleError)
trackEvent('InstanceStop', {
loader: process.instance.loader,
game_version: process.instance.game_version,
loader: process.profile.loader,
game_version: process.profile.game_version,
source: 'AppBar',
})
} catch (e) {
@@ -279,16 +275,15 @@ const stop = async (process: RunningProcess) => {
await refresh()
}
function goToTerminal(instanceId?: string) {
const selectedInstanceId = instanceId ?? selectedProcess.value?.instance.id
if (!selectedInstanceId) {
function goToTerminal(path?: string) {
const selectedPath = path ?? selectedProcess.value?.profile.path
if (!selectedPath) {
return
}
router.push(`/instance/${encodeURIComponent(selectedInstanceId)}/logs`)
router.push(`/instance/${encodeURIComponent(selectedPath)}/logs`)
}
const currentLoadingBars = ref<LoadingBar[]>([])
const currentLoadingBarIconUrls = ref<Record<string, string | null>>({})
const notificationId = ref<string | number | null>(null)
const dismissed = ref(false)
@@ -304,17 +299,8 @@ function getLoadingProgress(loadingBar: LoadingBar): number {
}
function getLoadingText(loadingBar: LoadingBar): string {
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)
const percent = Math.floor(getLoadingProgress(loadingBar) * 100)
return loadingBar.message ? `${percent}% ${loadingBar.message}` : `${percent}%`
}
function getNotification(): PopupNotification | null {
@@ -336,33 +322,24 @@ function removeNotification(): void {
}
function buildDownloadItems(): PopupNotificationProgressItem[] {
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,
})),
]
return currentLoadingBars.value.map((bar) => ({
id: getLoadingBarKey(bar),
title: bar.title ?? '',
text: getLoadingText(bar),
progress: getLoadingProgress(bar),
waiting: !bar.total || bar.total <= 0,
}))
}
const hasVisibleActiveDownloadToasts = computed(() => !!getNotification())
const hasActiveLoadingBars = computed(
() => currentLoadingBars.value.length > 0 || installJobNotifications.active.value,
)
const hasActiveLoadingBars = computed(() => currentLoadingBars.value.length > 0)
function updateNotification(resummon = false): void {
if (resummon) {
dismissed.value = false
}
if (currentLoadingBars.value.length === 0 && !installJobNotifications.active.value) {
if (currentLoadingBars.value.length === 0) {
removeNotification()
dismissed.value = false
return
@@ -381,23 +358,17 @@ function updateNotification(resummon = false): void {
const progressItems = buildDownloadItems()
if (notif) {
notif.title = installJobNotifications.active.value
? installJobNotifications.title.value
: formatMessage(messages.downloads)
notif.title = 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: installJobNotifications.active.value
? installJobNotifications.title.value
: formatMessage(messages.downloads),
title: formatMessage(messages.downloads),
type: 'download',
autoCloseMs: null,
progressItems,
buttons: installJobNotifications.buttons.value,
})
notificationId.value = notif.id
}
@@ -410,8 +381,8 @@ function formatLoadingBars(loadingBar: LoadingBar): LoadingBar {
version: formatted.bar_type.version,
})
}
if (formatted.bar_type?.instance_id) {
formatted.title = formatted.bar_type.instance_name ?? formatted.bar_type.instance_id
if (formatted.bar_type?.profile_path) {
formatted.title = formatted.bar_type.profile_path
}
if (formatted.bar_type?.pack_name) {
formatted.title = formatted.bar_type.pack_name
@@ -427,43 +398,7 @@ async function refreshLoadingBars() {
currentLoadingBars.value = Object.values(bars)
.map(formatLoadingBars)
.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]
}),
)
.filter((bar) => bar?.bar_type?.type !== 'launcher_update')
currentLoadingBars.value.sort((a, b) => {
const aKey = `${a.loading_bar_uuid ?? a.id ?? ''}`
@@ -474,12 +409,6 @@ async function refreshLoadingBars() {
updateNotification()
}
const installJobNotifications = await useInstallJobNotifications({
router,
handleError,
onChange: updateNotification,
})
await refreshLoadingBars()
const unlistenLoading = await loading_listener(async () => {
@@ -501,6 +430,5 @@ onBeforeUnmount(() => {
window.removeEventListener('online', handleOnline)
unlistenProcess()
unlistenLoading()
installJobNotifications.dispose()
})
</script>
@@ -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="!(isInstanceLink(item) && option.name === `add_content`)"
v-else-if="!(isLinkedData(item) && option.name === `add_content`)"
class="item clickable"
:class="[option.color ?? 'base']"
>
@@ -64,10 +64,10 @@ defineExpose({
},
})
const isInstanceLink = (item) => {
if (item.instance != undefined && item.instance.link) {
const isLinkedData = (item) => {
if (item.instance != undefined && item.instance.linked_data) {
return true
} else if (item != undefined && item.link) {
} else if (item != undefined && item.linked_data) {
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_existing_instance } from '@/helpers/install'
import { install } from '@/helpers/profile.js'
import { cancel_directory_change } from '@/helpers/settings.ts'
import { handleSevereError } from '@/store/error.js'
@@ -52,6 +52,10 @@ defineExpose({
if (errorVal.message.includes('because the target machine actively refused it')) {
metadata.value.hostsFile = true
}
} else if (errorVal.message && errorVal.message.includes('User is not logged in')) {
title.value = 'Sign in to Minecraft'
errorType.value = 'minecraft_sign_in'
supportLink.value = 'https://support.modrinth.com'
} else if (errorVal.message && errorVal.message.includes('Move directory error:')) {
title.value = 'Could not change app directory'
errorType.value = 'directory_move'
@@ -68,7 +72,7 @@ defineExpose({
title.value = 'No loader selected'
errorType.value = 'no_loader_version'
supportLink.value = 'https://support.modrinth.com'
metadata.value.instanceId = context.instanceId
metadata.value.profilePath = context.profilePath
} else if (source === 'state_init') {
title.value = 'Error initializing Modrinth App'
errorType.value = 'state_init'
@@ -121,7 +125,7 @@ const loadingRepair = ref(false)
async function repairInstance() {
loadingRepair.value = true
try {
await install_existing_instance(metadata.value.instanceId, false)
await install(metadata.value.profilePath, false)
errorModal.value.hide()
} catch (err) {
handleSevereError(err)
@@ -239,6 +243,20 @@ async function copyToClipboard(text) {
</button>
</div>
</template>
<div v-else-if="errorType === 'minecraft_sign_in'">
<p>
To play this instance, you must sign in through Microsoft below. If you don't have a
Minecraft account, you can purchase the game on the
<a href="https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc"
>Minecraft website</a
>.
</p>
<div class="cta-button">
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
<LogInIcon /> Sign in to Minecraft
</button>
</div>
</div>
<template v-else-if="errorType === 'state_init'">
<p>
Modrinth App failed to load correctly. This may be because of a corrupted file, or
@@ -1,32 +1,28 @@
<script setup>
import { XIcon } from '@modrinth/assets'
import { WrenchIcon, XIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
FileTreeSelect,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
import { readDir, stat } from '@tauri-apps/plugin-fs'
import { ref } from 'vue'
import { PackageIcon } from '@/assets/icons'
import {
export_instance_mrpack,
get_full_path,
get_pack_export_candidates,
} from '@/helpers/instance'
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'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: { id: 'app.export-modal.header', defaultMessage: 'Export modpack' },
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack name' },
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack Name' },
modpackNamePlaceholder: {
id: 'app.export-modal.modpack-name-placeholder',
defaultMessage: 'Modpack name',
@@ -43,7 +39,15 @@ const messages = defineMessages({
id: 'app.export-modal.description-placeholder',
defaultMessage: 'Enter modpack description...',
},
selectFilesLabel: {
id: 'app.export-modal.select-files-label',
defaultMessage: 'Configure which files are included in this export',
},
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
includeFile: {
id: 'app.export-modal.include-file-accessibility-label',
defaultMessage: 'Include "{file}"?',
},
})
const props = defineProps({
@@ -55,9 +59,8 @@ const props = defineProps({
defineExpose({
show: () => {
resetExportState()
exportModal.value.show()
void initFiles().catch(handleError)
initFiles()
},
})
@@ -66,33 +69,67 @@ const nameInput = ref(props.instance.name)
const exportDescription = ref('')
const versionInput = ref('1.0.0')
const files = ref([])
const selectedFilePaths = ref([])
const fileTreeKey = ref(0)
const filesLoadId = ref(0)
const instanceRoot = ref('')
const loadedDirectories = ref(new Set())
const folders = ref([])
async function initFiles() {
const loadId = ++filesLoadId.value
const [filePaths, root] = await Promise.all([
get_pack_export_candidates(props.instance.id),
get_full_path(props.instance.id),
])
if (loadId !== filesLoadId.value) return
instanceRoot.value = root
const exportCandidates = await Promise.all(
filePaths.map((path) => buildExportCandidateItem(root, path)),
const initFiles = async () => {
const newFolders = new Map()
const sep = '/'
files.value = []
await get_pack_export_candidates(props.instance.path).then((filePaths) =>
filePaths
.map((folder) => ({
path: folder,
name: folder.split(sep).pop(),
selected:
folder.startsWith('mods') ||
folder.startsWith('datapacks') ||
folder.startsWith('resourcepacks') ||
folder.startsWith('shaderpacks') ||
folder.startsWith('config'),
disabled:
folder === 'profile.json' ||
folder.startsWith('modrinth_logs') ||
folder.startsWith('.fabric'),
}))
.filter(
(pathData) =>
!pathData.path.includes('.DS_Store') &&
pathData.path !== 'mods/.connector' &&
!pathData.path.startsWith('mods/.connector/'),
)
.forEach((pathData) => {
const parent = pathData.path.split(sep).slice(0, -1).join(sep)
if (parent !== '') {
if (newFolders.has(parent)) {
newFolders.get(parent).push(pathData)
} else {
newFolders.set(parent, [pathData])
}
} else {
files.value.push(pathData)
}
}),
)
if (loadId !== filesLoadId.value) return
files.value = exportCandidates
selectedFilePaths.value = files.value
.filter((file) => !file.disabled && isDefaultSelectedExportCandidate(file.path))
.map((file) => file.path)
folders.value = [...newFolders.entries()].map(([name, value]) => [
{
name,
showingMore: false,
},
value,
])
}
await initFiles()
const exportPack = async () => {
const filesToExport = files.value.filter((file) => file.selected).map((file) => file.path)
folders.value.forEach((args) => {
args[1].forEach((child) => {
if (child.selected) {
filesToExport.push(child.path)
}
})
})
const outputPath = await save({
defaultPath: `${nameInput.value} ${versionInput.value}.mrpack`,
filters: [
@@ -104,10 +141,10 @@ const exportPack = async () => {
})
if (outputPath) {
export_instance_mrpack(
props.instance.id,
export_profile_mrpack(
props.instance.path,
outputPath,
selectedFilePaths.value,
filesToExport,
versionInput.value,
exportDescription.value,
nameInput.value,
@@ -115,176 +152,94 @@ const exportPack = async () => {
exportModal.value.hide()
}
}
function resetExportState() {
nameInput.value = props.instance.name
exportDescription.value = ''
versionInput.value = '1.0.0'
files.value = []
selectedFilePaths.value = []
fileTreeKey.value += 1
instanceRoot.value = ''
loadedDirectories.value = new Set()
}
async function loadExportDirectory(path) {
if (!path || !instanceRoot.value || loadedDirectories.value.has(path)) return
const loadId = filesLoadId.value
loadedDirectories.value.add(path)
try {
const entries = await readDir(`${instanceRoot.value}/${path}`)
const childItems = await Promise.all(
entries.map((entry) => buildExportDirectoryChildItem(instanceRoot.value, path, entry)),
)
if (loadId !== filesLoadId.value) return
appendExportItems(childItems)
} catch {
loadedDirectories.value.delete(path)
}
}
async function buildExportCandidateItem(instanceRoot, path) {
try {
const entries = await readDir(`${instanceRoot}/${path}`)
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'directory',
disabled: isExportCandidateDisabled(path),
modified: metadata.modified,
count: entries.length,
}
} catch {
return buildExportFileItem(instanceRoot, path)
}
}
async function buildExportDirectoryChildItem(instanceRoot, parentPath, entry) {
const path = `${parentPath}/${entry.name}`
if (entry.isDirectory) {
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'directory',
disabled: isExportCandidateDisabled(path),
modified: metadata.modified,
}
}
return buildExportFileItem(instanceRoot, path)
}
async function buildExportFileItem(instanceRoot, path) {
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'file',
disabled: isExportCandidateDisabled(path),
size: metadata.size,
modified: metadata.modified,
}
}
function appendExportItems(items) {
const nextFiles = new Map(files.value.map((file) => [normalizeExportPath(file.path), file]))
for (const item of items) {
nextFiles.set(normalizeExportPath(item.path), item)
}
files.value = [...nextFiles.values()]
}
async function getExportCandidateMetadata(instanceRoot, path) {
try {
const metadata = await stat(`${instanceRoot}/${path}`)
return {
size: metadata.size,
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : undefined,
}
} catch {
return {}
}
}
function normalizeExportPath(path) {
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
}
function isDefaultSelectedExportCandidate(path) {
return (
path.startsWith('mods') ||
path.startsWith('datapacks') ||
path.startsWith('resourcepacks') ||
path.startsWith('shaderpacks') ||
path.startsWith('config')
)
}
function isExportCandidateDisabled(path) {
return (
path === 'profile.json' ||
path.startsWith('modrinth_logs') ||
path.startsWith('.fabric') ||
path.startsWith('__MACOSX')
)
}
</script>
<template>
<NewModal
ref="exportModal"
:header="formatMessage(messages.header)"
scrollable
width="46rem"
max-width="calc(100vw - 2rem)"
>
<div class="flex flex-col gap-4">
<ModalWrapper ref="exportModal" :header="formatMessage(messages.header)">
<div class="flex flex-col gap-4 w-[40rem]">
<div class="grid grid-cols-2 gap-4">
<div class="labeled_input w-full">
<p class="text-contrast font-semibold">{{ formatMessage(messages.modpackNameLabel) }}</p>
<div class="labeled_input">
<p>{{ formatMessage(messages.modpackNameLabel) }}</p>
<StyledInput
v-model="nameInput"
:icon="PackageIcon"
type="text"
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
clearable
wrapper-class="w-full"
/>
</div>
<div class="labeled_input w-full">
<p class="text-contrast font-semibold">
{{ formatMessage(messages.versionNumberLabel) }}
</p>
<div class="labeled_input">
<p>{{ formatMessage(messages.versionNumberLabel) }}</p>
<StyledInput
v-model="versionInput"
:icon="VersionIcon"
type="text"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
clearable
wrapper-class="w-full"
/>
</div>
</div>
<div class="flex flex-col gap-2 min-w-0">
<p class="m-0 text-contrast font-semibold">
{{ formatMessage(commonMessages.descriptionLabel) }}
</p>
<div class="flex flex-col gap-2">
<p class="m-0">{{ formatMessage(commonMessages.descriptionLabel) }}</p>
<StyledInput
v-model="exportDescription"
multiline
:placeholder="formatMessage(messages.descriptionPlaceholder)"
wrapper-class="w-full"
/>
</div>
<FileTreeSelect
:key="fileTreeKey"
v-model="selectedFilePaths"
class="min-w-0"
:items="files"
@navigate="loadExportDirectory"
/>
</div>
<template #actions>
<Accordion
class="w-full bg-surface-4 border border-solid border-surface-5 rounded-2xl overflow-clip"
button-class="p-4 w-full border-b border-solid border-b-surface-5 bg-surface-2 -mb-px hover:brightness-[--hover-brightness] group"
>
<template #title>
<span class="flex items-center gap-3 text-contrast group-active:scale-[0.98]">
<WrenchIcon aria-hidden="true" class="size-5 text-secondary" />
Configure which files are included in this export
</span>
</template>
<div class="flex flex-col [&>*:nth-child(even)]:bg-surface-3">
<div v-for="[path, children] in folders" :key="path.name" class="flex flex-col">
<Accordion
class="flex flex-col"
button-class="flex gap-3 pr-4 hover:bg-surface-5 group"
>
<template #title>
<Checkbox
:model-value="children.every((child) => child.selected)"
:indeterminate="
!children.every((child) => child.selected) &&
children.some((child) => child.selected)
"
:description="formatMessage(messages.includeFile, { file: path.name })"
class="pl-4 py-2"
:disabled="children.every((x) => x.disabled)"
@update:model-value="
(newValue) => children.forEach((child) => (child.selected = newValue))
"
@click.stop
/>
<span class="ml-2 group-active:scale-95">{{ path.name }}/</span>
</template>
<div v-for="child in children" :key="child.path">
<Checkbox
v-model="child.selected"
:label="child.name"
class="w-full px-8 py-2 hover:bg-surface-4 text-primary"
:disabled="child.disabled"
/>
</div>
</Accordion>
</div>
<Checkbox
v-for="file in files"
:key="file.path"
v-model="file.selected"
:label="file.name"
:disabled="file.disabled"
class="w-full px-4 py-2 hover:bg-surface-4 text-primary"
/>
</div>
</Accordion>
<div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="exportModal.hide">
@@ -299,6 +254,6 @@ function isExportCandidateDisabled(path) {
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</div>
</ModalWrapper>
</template>
@@ -15,10 +15,9 @@ import { useRouter } from 'vue-router'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { get_by_profile_path } from '@/helpers/process'
import { finish_install, kill, run } from '@/helpers/profile'
import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
@@ -55,21 +54,20 @@ const installed = computed(() => props.instance.install_stage === 'installed')
const router = useRouter()
const seeInstance = async () => {
await router.push(`/instance/${encodeURIComponent(props.instance.id)}`)
await router.push(`/instance/${encodeURIComponent(props.instance.path)}`)
}
const checkProcess = async () => {
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError)
playing.value = runningProcesses.length > 0
}
const play = async (e, context) => {
e?.stopPropagation()
if (props.instance.quarantined) return
loading.value = true
await run(props.instance.id)
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
await run(props.instance.path)
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
.finally(() => {
trackEvent('InstanceStart', {
loader: props.instance.loader,
@@ -84,7 +82,7 @@ const stop = async (e, context) => {
e?.stopPropagation()
playing.value = false
await kill(props.instance.id).catch(handleError)
await kill(props.instance.path).catch(handleError)
trackEvent('InstanceStop', {
loader: props.instance.loader,
@@ -95,33 +93,18 @@ const stop = async (e, context) => {
const repair = async (e) => {
e?.stopPropagation()
if (props.instance.quarantined) return
if (
props.instance.install_stage !== 'pack_installed' &&
(props.instance.link?.type === 'modrinth_modpack' ||
props.instance.link?.type === 'server_project_modpack')
) {
await install_pack_to_existing_instance(props.instance.id, {
type: 'fromVersionId',
project_id: props.instance.link.project_id ?? props.instance.link.server_project_id ?? '',
version_id: props.instance.link.version_id ?? props.instance.link.content_version_id ?? '',
title: props.instance.name,
}).catch(handleError)
} else {
await install_existing_instance(props.instance.id, false).catch(handleError)
}
await finish_install(props.instance).catch(handleError)
}
const openFolder = async () => {
await showInstanceInFolder(props.instance.id)
await showProfileInFolder(props.instance.path)
}
const addContent = async () => {
if (props.instance.quarantined) return
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: props.instance.id },
query: { i: props.instance.path },
})
}
@@ -137,7 +120,7 @@ defineExpose({
const currentEvent = ref(null)
const unlisten = await process_listener((e) => {
if (e.instance_id === props.instance.id) {
if (e.profile_path_id === props.instance.path) {
currentEvent.value = e.event
if (e.event === 'finished') {
playing.value = false
@@ -145,9 +128,7 @@ const unlisten = await process_listener((e) => {
}
})
onMounted(() => {
checkProcess()
})
onMounted(() => checkProcess())
onUnmounted(() => unlisten())
</script>
@@ -161,7 +142,7 @@ onUnmounted(() => unlisten())
<Avatar
size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.id"
:tint-by="instance.path"
alt="Mod card"
/>
<div class="h-full flex items-center font-bold text-contrast leading-normal">
@@ -178,11 +159,7 @@ onUnmounted(() => unlisten())
<SpinnerIcon class="animate-spin" />
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="!instance.quarantined"
:color="first ? 'brand' : 'standard'"
circular
>
<ButtonStyled v-else :color="first ? 'brand' : 'standard'" circular>
<button
v-tooltip="'Play'"
@click="(e) => play(e, 'InstanceCard')"
@@ -214,7 +191,7 @@ onUnmounted(() => unlisten())
<Avatar
size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.id"
:tint-by="instance.path"
alt="Mod card"
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
@@ -236,12 +213,7 @@ onUnmounted(() => unlisten())
class="animate-spin w-8 h-8"
tabindex="-1"
/>
<ButtonStyled
v-else-if="!installed && !instance.quarantined"
size="large"
color="brand"
circular
>
<ButtonStyled v-else-if="!installed" size="large" color="brand" circular>
<button
v-tooltip="'Repair'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@@ -250,7 +222,7 @@ onUnmounted(() => unlisten())
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else-if="!instance.quarantined" size="large" color="brand" circular>
<ButtonStyled v-else size="large" color="brand" circular>
<button
v-tooltip="'Play'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@@ -22,7 +22,7 @@ const props = withDefaults(
)
const instanceLink = computed(() => {
const base = `/instance/${encodeURIComponent(props.instance.id)}`
const base = `/instance/${encodeURIComponent(props.instance.path)}`
return props.backTab ? `${base}/${props.backTab}` : base
})
</script>
@@ -1,33 +1,35 @@
<template>
<ModalWrapper ref="detectJavaModal" header="Select java version" :show-ad-on-close="false">
<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>
<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>
</ButtonStyled>
<ButtonStyled v-else>
<button class="!shadow-none" @click="setJavaInstall(row)"><PlusIcon /> Select</button>
<button @click="setJavaInstall(javaInstall)"><PlusIcon /> Select</button>
</ButtonStyled>
</div>
</template>
<template #empty-state>
<div class="p-4 text-secondary">No java installations found!</div>
</template>
</Table>
<div class="flex justify-end">
</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">
<ButtonStyled type="outlined">
<button
class="!shadow-none !border-surface-4 !border"
@click="$refs.detectJavaModal.hide()"
>
<button @click="$refs.detectJavaModal.hide()">
<XIcon />
Cancel
</button>
@@ -38,7 +40,7 @@
</template>
<script setup>
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, Table } from '@modrinth/ui'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
@@ -50,11 +52,6 @@ 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) => {
@@ -80,3 +77,25 @@ 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,104 +1,85 @@
<template>
<JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', 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>
<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,
})
}
"
/>
<span class="installation-buttons">
<ButtonStyled v-if="props.version">
<button
v-tooltip="testingJavaSuccess === true ? 'Already installed' : undefined"
class="!shadow-none"
:disabled="props.disabled || installingJava || testingJavaSuccess === true"
@click="reinstallJava"
>
<button :disabled="props.disabled || installingJava" @click="reinstallJava">
<DownloadIcon />
{{ installingJava ? 'Installing...' : 'Install recommended' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button class="!shadow-none" :disabled="props.disabled" @click="autoDetect">
<button :disabled="props.disabled" @click="autoDetect">
<SearchIcon />
Detect
</button>
</ButtonStyled>
<ButtonStyled>
<button class="!shadow-none" :disabled="props.disabled" @click="handleJavaFileInput()">
<button :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 {
CheckCircleIcon,
CheckIcon,
DownloadIcon,
FolderSearchIcon,
RefreshCwIcon,
PlayIcon,
SearchIcon,
SpinnerIcon,
XCircleIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, StyledInput } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
import { ref } 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 } from '@/helpers/jre.js'
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager()
const props = defineProps({
id: {
type: String,
required: false,
default: null,
},
version: {
type: Number,
required: false,
@@ -129,36 +110,29 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const {
testingJava,
javaTestResult: testingJavaSuccess,
testJavaInstallationDebounced,
testJavaInstallation,
} = useJavaTest()
const testingJava = ref(false)
const testingJavaSuccess = ref(null)
const installingJava = ref(false)
const hoveringTest = ref(false)
let hasInitialized = false
async function runTest(path) {
await testJavaInstallation(path, props.version, true)
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)
}
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()
@@ -168,7 +142,6 @@ async function handleJavaFileInput() {
result = {
path: filePath.path ?? filePath,
version: props.version.toString(),
parsed_version: props.version,
architecture: 'x86',
}
}
@@ -202,7 +175,6 @@ async function reinstallJava() {
result = {
path: path,
version: props.version.toString(),
parsed_version: props.version,
architecture: 'x86',
}
}
@@ -214,23 +186,13 @@ 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 {
flex: 1 1 0;
min-width: 0;
width: 100% !important;
flex-grow: 1;
}
.toggle-setting {
@@ -253,4 +215,12 @@ 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/instance'
import { update_managed_modrinth_version } from '@/helpers/profile'
import { releaseColor } from '@/helpers/utils'
const props = defineProps({
@@ -32,14 +32,14 @@ const filteredVersions = computed(() => {
})
const modpackVersionModal = ref(null)
const installedVersion = computed(() => props.instance?.link?.version_id)
const installedVersion = computed(() => props.instance?.linked_data?.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.id, versionId)
await update_managed_modrinth_version(props.instance.path, versionId)
inProgress.value = false
emit('finish-install')
}
@@ -59,7 +59,7 @@ const onHide = () => {
:on-hide="onHide"
>
<div class="modal-body">
<div v-if="instance.link" class="mod-card">
<div v-if="instance.linked_data" class="mod-card">
<div class="table">
<div class="table-row with-columns table-head">
<div class="table-cell table-text download-cell" />
@@ -1,123 +0,0 @@
<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 ease-out;
transition: width 0.3s;
}
</style>
@@ -6,16 +6,16 @@ import dayjs from 'dayjs'
import { onUnmounted, ref } from 'vue'
import NavButton from '@/components/ui/NavButton.vue'
import { instance_listener } from '@/helpers/events.js'
import { list } from '@/helpers/instance'
import { profile_listener } from '@/helpers/events.js'
import { list } from '@/helpers/profile'
const { handleError } = injectNotificationManager()
const recentInstances = ref([])
const getInstances = async () => {
const instances = await list().catch(handleError)
const profiles = await list().catch(handleError)
recentInstances.value = instances
recentInstances.value = profiles
.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 unlistenInstance = await instance_listener(async (event) => {
const unlistenProfile = await profile_listener(async (event) => {
if (event.event !== 'synced') {
await getInstances()
}
})
onUnmounted(() => {
unlistenInstance()
unlistenProfile()
})
</script>
<template>
<div v-for="instance in recentInstances" :key="instance.id" v-tooltip.right="instance.name">
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
<NavButton :to="`/instance/${encodeURIComponent(instance.path)}`" class="relative">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px"
:tint-by="instance.id"
:tint-by="instance.path"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
@@ -65,7 +65,10 @@ onUnmounted(() => {
</div>
</NavButton>
</div>
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-divider"></div>
<div
v-if="instances && 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 += 2
loadingProgress.value += 1
fakeLoadingIncrease()
}, 5)
}
@@ -22,7 +22,7 @@
hover-color-fill="background"
circular
>
<button class="relative expanded-button close-button" @click="handleClose">
<button class="relative expanded-button" @click="handleClose">
<XIcon />
</button>
</ButtonStyled>
@@ -82,12 +82,8 @@ const handleClose = async () => {
</script>
<style scoped>
.expanded-button::before {
inset: -9px -6px;
inset: -6px;
content: '';
position: absolute;
}
.expanded-button.close-button::before {
inset: -9px -9px -9px -6px;
}
</style>
@@ -1,124 +0,0 @@
<template>
<ButtonStyled color="brand" type="outlined" hover-color-fill="background">
<button
v-if="showUpdatePill"
type="button"
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out"
:class="{
'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill,
'opacity-100 scale-100': finishedDownloading && animateReadyPill,
}"
:disabled="isUpdateDownloading"
:aria-busy="isUpdateDownloading"
@click="handleUpdateClick"
>
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
<DownloadIcon v-else />
<span v-if="isUpdateDownloading">
{{ formatMessage(messages.downloadingUpdate) }}
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
</span>
<span v-else>{{ updateLabel }}</span>
</button>
</ButtonStyled>
</template>
<script setup lang="ts">
import { DownloadIcon, RefreshCwIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import {
appUpdateState,
downloadAvailableAppUpdate,
installAvailableAppUpdate,
} from '@/providers/app-update'
const { formatMessage } = useVIntl()
const messages = defineMessages({
update: {
id: 'app.action-bar.update',
defaultMessage: 'Update',
},
downloadingUpdate: {
id: 'app.action-bar.downloading-update',
defaultMessage: 'Downloading update',
},
reloadToUpdate: {
id: 'app.action-bar.reload-to-update',
defaultMessage: 'Reload to update',
},
})
const {
downloading,
downloadPercent,
downloadProgress,
finishedDownloading,
isVisible: isUpdateVisible,
metered,
restarting,
} = appUpdateState
const isUpdateDownloading = computed(
() =>
downloading.value ||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
)
const showUpdatePill = computed(
() => isUpdateVisible.value && (finishedDownloading.value || metered.value),
)
const animateReadyPill = ref(false)
const updateLabel = computed(() => {
if (isUpdateDownloading.value) {
return formatMessage(messages.downloadingUpdate)
}
if (finishedDownloading.value) {
return formatMessage(messages.reloadToUpdate)
}
return formatMessage(messages.update)
})
let readyPillAnimationFrame: number | null = null
watch([showUpdatePill, finishedDownloading], async ([show, ready], [wasShown, wasReady]) => {
if (readyPillAnimationFrame !== null) {
cancelAnimationFrame(readyPillAnimationFrame)
readyPillAnimationFrame = null
}
if (!show || !ready) {
animateReadyPill.value = false
return
}
if (wasShown && wasReady) {
return
}
animateReadyPill.value = false
await nextTick()
readyPillAnimationFrame = requestAnimationFrame(() => {
animateReadyPill.value = true
readyPillAnimationFrame = null
})
})
async function handleUpdateClick() {
if (isUpdateDownloading.value) {
return
}
if (finishedDownloading.value) {
await installAvailableAppUpdate()
} else {
await downloadAvailableAppUpdate()
}
}
onBeforeUnmount(() => {
if (readyPillAnimationFrame !== null) {
cancelAnimationFrame(readyPillAnimationFrame)
}
})
</script>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { MailIcon, SearchIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
@@ -10,12 +10,18 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { useFriends } from '@/composables/use-friends'
import type { FriendWithUserData } from '@/helpers/friends.ts'
import { friend_listener } from '@/helpers/events'
import {
add_friend,
friends,
type FriendWithUserData,
remove_friend,
transformFriends,
} from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
const { formatMessage } = useVIntl()
@@ -29,23 +35,36 @@ const props = defineProps<{
}>()
const userCredentials = computed(() => props.credentials)
const {
friends: userFriends,
loading,
requestFriend,
acceptFriend,
removeFriend: removeFriendRecord,
} = useFriends({
currentUserId: () => userCredentials.value?.user_id,
getCredentials: () => userCredentials.value,
onError: handleError,
})
const search = ref('')
const friendInvitesModal = ref()
const username = ref('')
const addFriendModal = ref()
async function addFriendFromModal() {
addFriendModal.value.hide()
await add_friend(username.value).catch(handleError)
username.value = ''
await loadFriends()
}
async function addFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await add_friend(id).catch(handleError)
await loadFriends()
}
}
async function removeFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await remove_friend(id).catch(handleError)
await loadFriends()
}
}
const userFriends = ref<FriendWithUserData[]>([])
const sortedFriends = computed<FriendWithUserData[]>(() =>
userFriends.value.slice().sort((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
@@ -89,22 +108,42 @@ const incomingRequests = computed(() =>
.sort((a, b) => b.created.diff(a.created)),
)
function addFriendFromModal() {
const target = username.value.trim()
if (!target) return
const loading = ref(true)
async function loadFriends(timeout = false) {
loading.value = timeout
addFriendModal.value.hide()
requestFriend({ id: target, username: target })
username.value = ''
try {
const friendsList = await friends()
userFriends.value = await transformFriends(friendsList, userCredentials.value)
loading.value = false
} catch (e) {
console.error('Error loading friends', e)
if (timeout) {
setTimeout(() => loadFriends(), 15 * 1000)
}
}
}
function addFriend(friend: FriendWithUserData) {
acceptFriend(friend)
}
watch(
userCredentials,
() => {
if (userCredentials.value === undefined) {
userFriends.value = []
loading.value = false
} else if (userCredentials.value === null) {
userFriends.value = []
loading.value = false
} else {
loadFriends(true)
}
},
{ immediate: true },
)
function removeFriend(friend: FriendWithUserData) {
removeFriendRecord(friend)
}
const unlisten = await friend_listener(() => loadFriends())
onUnmounted(() => {
unlisten()
})
const messages = defineMessages({
addFriend: {
@@ -262,12 +301,11 @@ const messages = defineMessages({
</ButtonStyled>
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchFriends)"
clearable
input-class="!bg-transparent !border !border-solid !border-button-bg !text-primary !placeholder:text-primary"
wrapper-class="flex-1 [&>svg]:!text-primary [&>svg]:!opacity-100"
variant="outlined"
wrapper-class="flex-1"
@keyup.esc="search = ''"
/>
</template>
@@ -355,6 +393,7 @@ 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,28 +7,26 @@ 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/instance'
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
import { list } from '@/helpers/profile'
import { add_server_to_profile, get_profile_worlds } from '@/helpers/worlds.ts'
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const modal = ref()
const searchFilter = ref('')
const instances = ref([])
const profiles = ref([])
const serverName = ref('')
const serverAddress = ref('')
const shownInstances = computed(() =>
instances.value.filter((instance) => {
return instance.name.toLowerCase().includes(searchFilter.value.toLowerCase())
const shownProfiles = computed(() =>
profiles.value.filter((profile) => {
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
}),
)
@@ -38,15 +36,15 @@ defineExpose({
serverAddress.value = address
searchFilter.value = ''
const instanceValues = await list().catch(handleError)
const profilesVal = await list().catch(handleError)
await Promise.allSettled(
instanceValues.map(async (instance) => {
instance.adding = false
instance.added = false
profilesVal.map(async (profile) => {
profile.adding = false
profile.added = false
try {
const worlds = await get_instance_worlds(instance.id)
instance.added = worlds.some(
const worlds = await get_profile_worlds(profile.path)
profile.added = worlds.some(
(w) => w.type === 'server' && w.address === serverAddress.value,
)
} catch {
@@ -55,29 +53,28 @@ defineExpose({
}),
)
instances.value = instanceValues
profiles.value = profilesVal
modal.value.show()
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
},
})
async function addServer(instance) {
instance.adding = true
async function addServer(profile) {
profile.adding = true
try {
await add_server_to_instance(instance.id, serverName.value, serverAddress.value, 'prompt')
instance.added = true
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
await add_server_to_profile(profile.path, serverName.value, serverAddress.value, 'prompt')
profile.added = true
trackEvent('AddServerToInstance', {
server_name: serverName.value,
instance_name: instance.name,
instance_name: profile.name,
source: 'AddServerToInstanceModal',
})
} catch (err) {
handleError(err)
}
instance.adding = false
profile.adding = false
}
</script>
@@ -94,26 +91,26 @@ async function addServer(instance) {
/>
<div class="max-h-[21rem] overflow-y-auto">
<div
v-for="instance in shownInstances"
:key="instance.id"
v-for="profile in shownProfiles"
:key="profile.path"
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(instance.id)}`"
:to="`/instance/${encodeURIComponent(profile.path)}`"
@click="modal.hide()"
>
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:src="profile.icon_path ? convertFileSrc(profile.icon_path) : null"
class="mr-2 [--size:2rem]"
/>
{{ instance.name }}
{{ profile.name }}
</router-link>
<ButtonStyled>
<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 :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>
</ButtonStyled>
</div>
@@ -0,0 +1,185 @@
<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>
@@ -1,41 +1,108 @@
<template>
<UnknownFileWarningModal
ref="modal"
:mode="externalFilesInModpack.length > 0 ? 'modpack' : 'mod'"
:file-name="fileName"
:external-files-in-modpack="externalFilesInModpack"
@cancel="reset"
@continue="proceed"
/>
<NewModal ref="modal" :header="formatMessage(messages.header)" :on-hide="reset">
<div class="max-w-[31rem] flex flex-col gap-6">
<Admonition
type="warning"
:header="formatMessage(messages.warningTitle)"
:body="formatMessage(messages.warningBody)"
/>
<div v-if="fileName" class="overflow-x-auto whitespace-nowrap text-sm text-secondary">
{{ fileName }}
</div>
<div>
<p class="mt-0 leading-tight">
{{ formatMessage(messages.body) }}
</p>
<p class="text-orange font-semibold mb-0 leading-tight">
{{ formatMessage(messages.malwareStatement) }}
</p>
</div>
<Checkbox v-model="dontShowAgain" :label="formatMessage(messages.dontShowAgain)" />
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="cancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button :disabled="isProceeding" @click="proceed">
<SpinnerIcon v-if="isProceeding" class="animate-spin" />
<CircleArrowRightIcon v-else />
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { UnknownFileWarningModal } from '@modrinth/ui'
import { CircleArrowRightIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme.ts'
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
const dontShowAgain = ref(false)
const modal = useTemplateRef('modal')
const onProceed = ref<() => Promise<void>>()
const isProceeding = ref(false)
const fileName = ref('')
const externalFilesInModpack = ref<string[]>([])
function show(
createInstance: () => Promise<void>,
selectedFileName = '',
selectedExternalFiles: string[] = [],
) {
const messages = defineMessages({
header: {
id: 'unknown-pack-warning-modal.header',
defaultMessage: 'Confirm installation',
},
warningTitle: {
id: 'unknown-pack-warning-modal.warning.title',
defaultMessage: 'Unknown file warning',
},
warningBody: {
id: 'unknown-pack-warning-modal.warning.body',
defaultMessage: `We couldn't find this file on Modrinth. We strongly recommend only installing files from sources you trust.`,
},
body: {
id: 'unknown-pack-warning-modal.body',
defaultMessage: `A file is only reviewed if its uploaded to Modrinth, regardless of its file format (including .mrpack).`,
},
malwareStatement: {
id: 'unknown-pack-warning-modal.malware-statement',
defaultMessage: `Malware is often distributed through modpack files by sharing them on platforms like Discord.`,
},
dontShowAgain: {
id: 'unknown-pack-warning-modal.dont-show-again',
defaultMessage: `Don't show this warning again`,
},
installAnyway: {
id: 'unknown-pack-warning-modal.install-anyway',
defaultMessage: `Install anyway`,
},
})
function show(createInstance: () => Promise<void>, selectedFileName = '') {
onProceed.value = createInstance
fileName.value = selectedFileName
externalFilesInModpack.value = selectedExternalFiles
dontShowAgain.value = false
if (themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
void createInstance()
// noinspection ES6MissingAwait
createInstance()
return
}
@@ -45,11 +112,18 @@ function show(
function reset() {
onProceed.value = undefined
fileName.value = ''
externalFilesInModpack.value = []
}
async function proceed(dontShowAgain: boolean) {
if (dontShowAgain) {
function cancel() {
modal.value?.hide()
}
async function proceed() {
if (!onProceed.value) {
return
}
if (dontShowAgain.value) {
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = true
const settings = await getSettings()
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = true
@@ -57,8 +131,9 @@ async function proceed(dontShowAgain: boolean) {
}
const createInstance = onProceed.value
reset()
if (createInstance) void createInstance()
modal.value?.hide()
// noinspection ES6MissingAwait
createInstance()
}
defineExpose({ show })
@@ -1,408 +0,0 @@
<template>
<PageHeader :title="instance.name">
<template #leading>
<Avatar :src="iconSrc" :alt="instance.name" size="64px" :tint-by="instance.id" />
</template>
<template v-if="instance.shared_instance || instance.quarantined" #badges>
<PageHeaderBadgeItem
v-if="instance.quarantined"
:icon="LockIcon"
aria-label="Locked instance information"
class="!border-orange !bg-highlight-orange !text-orange"
>
Locked
</PageHeaderBadgeItem>
<PageHeaderBadgeItem
v-else
:icon="UnknownIcon"
:tooltip="sharedInstanceTooltip"
aria-label="Shared instance information"
class="!border-blue !bg-highlight-blue !text-blue"
>
Shared
</PageHeaderBadgeItem>
</template>
<template #metadata>
<div v-if="isServerInstance" class="flex flex-wrap items-center gap-2">
<InstanceHeaderServerMetadata
:loading-server-ping="loadingServerPing"
:players-online="playersOnline"
:status-online="statusOnline"
:recent-plays="recentPlays"
:ping="ping"
:minecraft-server="minecraftServer"
:linked-project-v3="linkedProjectV3"
:instance-id="instance.id"
/>
<PageHeaderMetadataItem v-if="sharedInstanceManager" :action="sharedInstanceManagerAction">
{{ sharedInstanceManagerLabel }}
<Avatar
:src="sharedInstanceManager.avatarUrl"
:alt="sharedInstanceManager.name"
:tint-by="sharedInstanceManager.tintBy"
size="24px"
:circle="sharedInstanceManager.type === 'user'"
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceManager.name }}</span>
</PageHeaderMetadataItem>
</div>
<PageHeaderMetadata v-else>
<PageHeaderMetadataItem :icon="Gamepad2Icon" tooltip="Minecraft version">
Minecraft {{ instance.game_version }}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem
v-if="sharedInstanceManager?.type !== 'user'"
:icon="ServerLoaderIcon"
:icon-props="{ loader: loaderDisplayName }"
tooltip="Mod loader"
>
{{ loaderLabel }}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem
v-if="showInstancePlayTime"
:icon="TimerIcon"
tooltip="Total playtime"
>
{{ playtimeLabel }}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem v-if="sharedInstanceManager" :action="sharedInstanceManagerAction">
{{ sharedInstanceManagerLabel }}
<Avatar
:src="sharedInstanceManager.avatarUrl"
:alt="sharedInstanceManager.name"
:tint-by="sharedInstanceManager.tintBy"
size="24px"
:circle="sharedInstanceManager.type === 'user'"
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceManager.name }}</span>
</PageHeaderMetadataItem>
</PageHeaderMetadata>
</template>
<template #actions>
<PageHeaderActions>
<ButtonStyled v-if="isInstalling" color="brand" size="large">
<button type="button" disabled>
{{ formatMessage(commonMessages.installingLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing" color="red" size="large">
<button type="button" :disabled="stopping" @click="emit('stop')">
<StopCircleIcon />
{{
stopping ? formatMessage(messages.stopping) : formatMessage(commonMessages.stopButton)
}}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="instance.quarantined" color="brand" size="large">
<button v-tooltip="formatMessage(messages.lockedPlayTooltip)" type="button" disabled>
<PlayIcon />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="instance.install_stage !== 'installed'" color="brand" size="large">
<button type="button" @click="emit('repair')">
<DownloadIcon />
{{ formatMessage(messages.repair) }}
</button>
</ButtonStyled>
<JoinedButtons
v-else-if="!loading && isServerInstance"
:actions="serverPlayActions"
color="brand"
size="large"
/>
<ButtonStyled v-else-if="!loading" color="brand" size="large">
<button type="button" @click="emit('play')">
<PlayIcon />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" size="large">
<button type="button" disabled>{{ formatMessage(messages.starting) }}</button>
</ButtonStyled>
<ButtonStyled circular size="large">
<button
v-tooltip="formatMessage(messages.instanceSettings)"
type="button"
:aria-label="formatMessage(messages.instanceSettings)"
@click="emit('settings')"
>
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled circular size="large" type="transparent">
<TeleportOverflowMenu
:options="moreActions"
:tooltip="formatMessage(messages.moreActions)"
:aria-label="formatMessage(messages.moreActions)"
>
<MoreVerticalIcon />
</TeleportOverflowMenu>
</ButtonStyled>
</PageHeaderActions>
</template>
</PageHeader>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
DownloadIcon,
ExternalIcon,
FolderOpenIcon,
LockIcon,
MoreVerticalIcon,
PackageIcon,
PlayIcon,
ReportIcon,
SettingsIcon,
StopCircleIcon,
TagCategoryGamepad2Icon as Gamepad2Icon,
TimerIcon,
UnknownIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
formatLoaderLabel,
type JoinedButtonAction,
JoinedButtons,
LoaderIcon as ServerLoaderIcon,
PageHeader,
PageHeaderActions,
PageHeaderBadgeItem,
PageHeaderMetadata,
PageHeaderMetadataItem,
type ServerLoader,
TeleportOverflowMenu,
type TeleportOverflowMenuItem,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed } from 'vue'
import type { GameInstance } from '@/helpers/types'
import InstanceHeaderServerMetadata from './instance-page-header-server-metadata.vue'
const messages = defineMessages({
createShortcut: {
id: 'instance.action.create-shortcut',
defaultMessage: 'Create shortcut',
},
exportModpack: {
id: 'instance.action.export-modpack',
defaultMessage: 'Export modpack',
},
instanceSettings: {
id: 'instance.action.settings',
defaultMessage: 'Instance settings',
},
launchInstance: {
id: 'instance.action.launch-instance',
defaultMessage: 'Launch instance',
},
moreActions: {
id: 'instance.action.more-actions',
defaultMessage: 'More actions',
},
neverPlayed: {
id: 'instance.playtime.never-played',
defaultMessage: 'Never played',
},
openFolder: {
id: 'instance.action.open-folder',
defaultMessage: 'Open folder',
},
repair: {
id: 'instance.action.repair',
defaultMessage: 'Repair',
},
lockedPlayTooltip: {
id: 'instance.locked.play-tooltip',
defaultMessage: 'This instance has been locked',
},
starting: {
id: 'instance.action.starting',
defaultMessage: 'Starting...',
},
stopping: {
id: 'instance.action.stopping',
defaultMessage: 'Stopping...',
},
sharedInstanceTooltip: {
id: 'instance.shared-instance.tooltip',
defaultMessage: "This instance's content is being managed by someone else.",
},
sharedInstanceOwnerTooltip: {
id: 'instance.shared-instance.owner-tooltip',
defaultMessage: "This instance's content is being shared to other users.",
},
})
const props = withDefaults(
defineProps<{
instance: GameInstance
iconSrc?: string | null
isServerInstance?: boolean
showInstancePlayTime?: boolean
timePlayed?: number
playing?: boolean
loading?: boolean
stopping?: boolean
loadingServerPing?: boolean
playersOnline?: number
statusOnline?: boolean
recentPlays?: number
ping?: number
minecraftServer?: Labrinth.Projects.v3.Project['minecraft_server']
linkedProjectV3?: Labrinth.Projects.v3.Project
sharedInstanceManager?: {
type: 'user' | 'server'
name: string
avatarUrl?: string
tintBy: string
} | null
}>(),
{
iconSrc: null,
isServerInstance: false,
showInstancePlayTime: false,
timePlayed: 0,
playing: false,
loading: false,
stopping: false,
loadingServerPing: false,
playersOnline: undefined,
statusOnline: false,
recentPlays: undefined,
ping: undefined,
minecraftServer: undefined,
linkedProjectV3: undefined,
sharedInstanceManager: null,
},
)
const emit = defineEmits<{
repair: []
stop: []
play: []
playServer: []
settings: []
openFolder: []
export: []
createShortcut: []
report: [event?: MouseEvent]
}>()
const installingStages = [
'installing',
'pack_installing',
'pack_installed',
'not_installed',
'minecraft_installing',
]
const { formatMessage } = useVIntl()
const isInstalling = computed(() => installingStages.includes(props.instance.install_stage))
const loaderDisplayName = computed(() => formatLoaderLabel(props.instance.loader) as ServerLoader)
const loaderLabel = computed(() =>
[loaderDisplayName.value, props.instance.loader_version].filter(Boolean).join(' '),
)
const sharedInstanceTooltip = computed(() =>
formatMessage(
props.instance.shared_instance?.role === 'owner'
? messages.sharedInstanceOwnerTooltip
: messages.sharedInstanceTooltip,
),
)
const sharedInstanceManagerLabel = computed(() =>
props.sharedInstanceManager?.type === 'server' ? 'Linked to' : 'Managed by',
)
const sharedInstanceManagerAction = computed(() => {
const manager = props.sharedInstanceManager
if (manager?.type !== 'user') return undefined
return () => openUrl(`https://modrinth.com/user/${encodeURIComponent(manager.name)}`)
})
const playtimeLabel = computed(() => {
if (props.timePlayed <= 0) return formatMessage(messages.neverPlayed)
const hours = Math.floor(props.timePlayed / 3600)
if (hours >= 1) {
return `${hours} hour${hours > 1 ? 's' : ''}`
}
const minutes = Math.floor(props.timePlayed / 60)
if (minutes >= 1) {
return `${minutes} minute${minutes > 1 ? 's' : ''}`
}
const seconds = Math.floor(props.timePlayed)
return `${seconds} second${seconds > 1 ? 's' : ''}`
})
const serverPlayActions = computed<JoinedButtonAction[]>(() => [
{
id: 'join_server',
label: formatMessage(commonMessages.playButton),
icon: PlayIcon,
action: () => emit('playServer'),
},
{
id: 'launch_instance',
label: formatMessage(messages.launchInstance),
icon: PlayIcon,
action: () => emit('play'),
},
])
const moreActions = computed<TeleportOverflowMenuItem[]>(() => {
const actions: TeleportOverflowMenuItem[] = [
{
id: 'open-folder',
label: formatMessage(messages.openFolder),
icon: FolderOpenIcon,
action: () => emit('openFolder'),
},
]
if (!props.instance.quarantined) {
actions.push(
{
id: 'export-mrpack',
label: formatMessage(messages.exportModpack),
icon: PackageIcon,
action: () => emit('export'),
},
{
id: 'create-shortcut',
label: formatMessage(messages.createShortcut),
icon: ExternalIcon,
action: () => emit('createShortcut'),
},
)
}
if (props.instance.shared_instance?.role === 'member') {
actions.push(
{ divider: true },
{
id: 'report-shared-instance',
label: formatMessage(commonMessages.reportButton),
icon: ReportIcon,
color: 'red',
action: (event) => emit('report', event),
},
)
}
return actions
})
</script>
@@ -1,63 +0,0 @@
<template>
<div class="flex items-center flex-wrap gap-2">
<template v-if="loadingServerPing">
<ServerOnlinePlayers
v-if="playersOnline !== undefined"
:online="playersOnline"
:status-online="statusOnline"
hide-label
/>
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
<div
v-if="
(playersOnline !== undefined || recentPlays !== undefined) &&
(minecraftServer?.region || ping)
"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<ServerPing v-if="ping" :ping="ping" />
</template>
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
<div v-if="minecraftServer?.region || ping" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div v-if="linkedProjectV3" class="flex gap-1.5 items-center font-medium text-primary">
Linked to
<Avatar
:src="linkedProjectV3.icon_url"
:alt="linkedProjectV3.name"
:tint-by="instanceId"
size="24px"
/>
<router-link
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
class="hover:underline text-primary truncate"
>
{{ linkedProjectV3.name }}
</router-link>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
Avatar,
ServerOnlinePlayers,
ServerPing,
ServerRecentPlays,
ServerRegion,
} from '@modrinth/ui'
defineProps<{
loadingServerPing?: boolean
playersOnline?: number
statusOnline?: boolean
recentPlays?: number
ping?: number
minecraftServer?: Labrinth.Projects.v3.Project['minecraft_server']
linkedProjectV3?: Labrinth.Projects.v3.Project
instanceId?: string
}>()
</script>
@@ -1,116 +0,0 @@
<template>
<StackedAdmonitions v-bind="$attrs" :items="stackItems" class="w-full">
<template #item="{ item, dismissible }">
<InstanceAdmonitionsSharedInstanceStale
v-if="item.kind === 'shared-instance-stale'"
:instance="instance"
@published="emit('published')"
/>
<InstanceAdmonitionsSharedInstanceWrongAccount
v-else-if="item.kind === 'shared-instance-wrong-account'"
:expected-user-id="sharedInstanceExpectedUserId"
:role="sharedInstanceRole"
:signed-out="sharedInstanceSignedOut"
/>
<InstanceAdmonitionsSharedInstanceUnavailable
v-else-if="item.kind === 'shared-instance-unavailable'"
:reason="displayedSharedInstanceUnavailableReason"
:manager="sharedInstanceUnavailableManager"
:dismissible="dismissible"
@dismiss="sharedInstanceUnavailableDismissed = true"
@delete="emit('delete')"
/>
</template>
</StackedAdmonitions>
</template>
<script setup lang="ts">
import { StackedAdmonitions } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
import type { GameInstance } from '@/helpers/types'
import InstanceAdmonitionsSharedInstanceStale from './instance-admonitions-shared-instance-stale.vue'
import InstanceAdmonitionsSharedInstanceUnavailable from './instance-admonitions-shared-instance-unavailable.vue'
import InstanceAdmonitionsSharedInstanceWrongAccount from './instance-admonitions-shared-instance-wrong-account.vue'
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<{
instance: GameInstance
sharedInstanceUnavailableReason?: SharedInstanceUnavailableReason | null
sharedInstanceUnavailableManager?: string | null
sharedInstanceWrongAccount?: boolean
sharedInstanceExpectedUserId?: string | null
sharedInstanceRole?: SharedInstanceRole | null
sharedInstanceSignedOut?: boolean
}>()
const emit = defineEmits<{
published: []
delete: []
}>()
const sharedInstanceWrongAccount = computed(() => props.sharedInstanceWrongAccount ?? false)
const displayedSharedInstanceUnavailableReason = computed<SharedInstanceUnavailableReason | null>(
() =>
props.instance.quarantined ? 'quarantined' : (props.sharedInstanceUnavailableReason ?? null),
)
const sharedInstanceUnavailableDismissed = ref(false)
const showSharedInstancePublishAdmonition = computed(
() =>
!sharedInstanceWrongAccount.value &&
props.instance.install_stage === 'installed' &&
props.instance.shared_instance?.role === 'owner' &&
props.instance.shared_instance.status === 'stale',
)
const stackItems = computed<InstanceAdmonitionItem[]>(() => {
const items: InstanceAdmonitionItem[] = []
if (sharedInstanceWrongAccount.value) {
items.push({
id: 'shared-instance-wrong-account',
type: 'warning',
dismissible: false,
kind: 'shared-instance-wrong-account',
})
}
const unavailableReason = displayedSharedInstanceUnavailableReason.value
const sharedInstanceQuarantined = unavailableReason === 'quarantined'
if (
unavailableReason &&
(sharedInstanceQuarantined || !sharedInstanceUnavailableDismissed.value)
) {
items.push({
id: 'shared-instance-unavailable',
type: 'warning',
dismissible: !sharedInstanceQuarantined,
kind: 'shared-instance-unavailable',
})
}
if (showSharedInstancePublishAdmonition.value) {
items.push({
id: 'shared-instance-stale',
type: 'warning',
dismissible: false,
kind: 'shared-instance-stale',
})
}
return items
})
watch(
() => [props.instance.id, displayedSharedInstanceUnavailableReason.value],
() => {
sharedInstanceUnavailableDismissed.value = false
},
)
</script>
@@ -1,69 +0,0 @@
import { defineMessages } from '@modrinth/ui'
export const instanceAdmonitionsMessages = defineMessages({
sharedInstanceChangesHeader: {
id: 'app.instance.admonitions.shared-instance.changes-header',
defaultMessage: "Your changes haven't been shared yet",
},
sharedInstanceChangesBody: {
id: 'app.instance.admonitions.shared-instance.changes-body',
defaultMessage: "Your local instance is ahead of the users you've shared it with.",
},
sharedInstancePublishButton: {
id: 'app.instance.admonitions.shared-instance.publish-button',
defaultMessage: 'Push update',
},
sharedInstancePublishingButton: {
id: 'app.instance.admonitions.shared-instance.publishing-button',
defaultMessage: 'Pushing...',
},
sharedInstanceReviewingButton: {
id: 'app.instance.admonitions.shared-instance.reviewing-button',
defaultMessage: 'Reviewing...',
},
sharedInstanceReviewHeader: {
id: 'app.instance.admonitions.shared-instance.review-header',
defaultMessage: 'Review changes',
},
sharedInstanceReviewAdmonitionHeader: {
id: 'app.instance.admonitions.shared-instance.review-admonition-header',
defaultMessage: 'Push update',
},
sharedInstanceReviewDescription: {
id: 'app.instance.admonitions.shared-instance.review-description',
defaultMessage:
'Review the content changes that will be shared with everyone using this instance.',
},
sharedInstanceAddedLabel: {
id: 'app.instance.admonitions.shared-instance.added-label',
defaultMessage: 'Added',
},
sharedInstanceRemovedLabel: {
id: 'app.instance.admonitions.shared-instance.removed-label',
defaultMessage: 'Removed',
},
sharedInstanceWrongAccountHeader: {
id: 'app.instance.shared-instance-wrong-account.warning-header',
defaultMessage: 'You are using the wrong Modrinth account',
},
sharedInstanceSignedOutHeader: {
id: 'app.instance.shared-instance-wrong-account.signed-out-header',
defaultMessage: 'You need to sign in to Modrinth',
},
sharedInstanceWrongAccountSignInAs: {
id: 'app.instance.shared-instance-wrong-account.sign-in-as-label',
defaultMessage: 'Sign in as',
},
sharedInstanceWrongAccountUserBody: {
id: 'app.instance.shared-instance-wrong-account.user-admonition-body-v2',
defaultMessage: 'to receive updates for this shared instance.',
},
sharedInstanceWrongAccountOwnerBody: {
id: 'app.instance.shared-instance-wrong-account.owner-admonition-body-v2',
defaultMessage: "to manage this shared instance. You won't be able to push updates to users.",
},
sharedInstanceWrongAccountFallbackUsername: {
id: 'app.instance.shared-instance-wrong-account.fallback-username',
defaultMessage: 'the linked account',
},
})
@@ -1,66 +0,0 @@
<template>
<Admonition
type="info"
inline-actions
:header="formatMessage(messages.sharedInstanceChangesHeader)"
>
{{ formatMessage(messages.sharedInstanceChangesBody) }}
<template #actions>
<ButtonStyled color="blue">
<button class="!h-10" :disabled="isPublishButtonDisabled" @click="reviewChanges">
<SpinnerIcon
v-if="isReviewingPublish || isPublishing"
class="animate-spin"
aria-hidden="true"
/>
<UploadIcon v-else aria-hidden="true" />
{{
isPublishing
? formatMessage(messages.sharedInstancePublishingButton)
: isReviewingPublish
? formatMessage(messages.sharedInstanceReviewingButton)
: formatMessage(messages.sharedInstancePublishButton)
}}
</button>
</ButtonStyled>
</template>
</Admonition>
<SharedInstancePublishModal
ref="publishModal"
:instance="instance"
@published="emit('published')"
@state-change="publishState = $event"
/>
</template>
<script setup lang="ts">
import { SpinnerIcon, UploadIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
import type { GameInstance } from '@/helpers/types'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
defineProps<{
instance: GameInstance
}>()
const emit = defineEmits<{
published: []
}>()
const { formatMessage } = useVIntl()
const publishModal = ref<InstanceType<typeof SharedInstancePublishModal>>()
const publishState = ref<'idle' | 'reviewing' | 'publishing'>('idle')
const isPublishing = computed(() => publishState.value === 'publishing')
const isReviewingPublish = computed(() => publishState.value === 'reviewing')
const isPublishButtonDisabled = computed(() => isPublishing.value || isReviewingPublish.value)
function reviewChanges(e?: MouseEvent) {
publishModal.value?.show(e)
}
</script>
@@ -1,51 +0,0 @@
<template>
<Admonition
type="warning"
:inline-actions="reason === 'quarantined'"
:header="formatMessage(sharedInstanceUnavailableTitleMessage(reason ?? null))"
:dismissible="dismissible"
@dismiss="emit('dismiss')"
>
{{ formatSharedInstanceUnavailable(reason ?? null, manager) }}
<template v-if="reason === 'quarantined'" #actions>
<ButtonStyled color="orange">
<button class="!h-10" @click="emit('delete')">
<TrashIcon aria-hidden="true" />
{{ formatMessage(messages.deleteInstance) }}
</button>
</ButtonStyled>
</template>
</Admonition>
</template>
<script setup lang="ts">
import { TrashIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
import {
sharedInstanceUnavailableTitleMessage,
useSharedInstanceErrors,
} from '@/helpers/shared-instance-errors'
defineProps<{
reason?: SharedInstanceUnavailableReason | null
manager?: string | null
dismissible?: boolean
}>()
const emit = defineEmits<{
dismiss: []
delete: []
}>()
const { formatMessage } = useVIntl()
const { formatSharedInstanceUnavailable } = useSharedInstanceErrors()
const messages = defineMessages({
deleteInstance: {
id: 'instance.locked.delete-button',
defaultMessage: 'Delete instance',
},
})
</script>
@@ -1,78 +0,0 @@
<template>
<Admonition type="warning" :header="formatMessage(headerMessage)">
<span class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span>{{ formatMessage(messages.sharedInstanceWrongAccountSignInAs) }}</span>
<span
v-if="sharedInstanceExpectedUser"
class="inline-flex max-w-full min-w-0 items-center gap-1.5 align-middle font-semibold text-contrast"
>
<Avatar
:src="sharedInstanceExpectedUser.avatarUrl"
:alt="sharedInstanceExpectedUsername"
:tint-by="sharedInstanceExpectedUser.tintBy"
size="20px"
circle
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceExpectedUsername }}</span>
</span>
<span v-else class="font-semibold">{{ sharedInstanceExpectedUsername }}</span>
<span>{{ formatMessage(bodyMessage) }}</span>
</span>
</Admonition>
</template>
<script setup lang="ts">
import { Admonition, Avatar, useVIntl } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { get_user } from '@/helpers/cache'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
import type { SharedInstanceRole } from './types'
const props = defineProps<{
expectedUserId?: string | null
role?: SharedInstanceRole | null
signedOut?: boolean
}>()
const { formatMessage } = useVIntl()
const expectedUserId = computed(() => props.expectedUserId ?? null)
const expectedUserQuery = useQuery({
queryKey: computed(() => ['user', expectedUserId.value]),
queryFn: async () => {
if (!expectedUserId.value) return null
return await get_user(expectedUserId.value, 'bypass').catch(() => null)
},
enabled: () => !!expectedUserId.value,
staleTime: 30_000,
})
const sharedInstanceExpectedUser = computed(() => {
const user = expectedUserQuery.data.value
if (!user) return null
return {
username: user.username,
avatarUrl: user.avatar_url ?? undefined,
tintBy: user.id,
}
})
const sharedInstanceExpectedUsername = computed(
() =>
sharedInstanceExpectedUser.value?.username ||
formatMessage(messages.sharedInstanceWrongAccountFallbackUsername),
)
const headerMessage = computed(() =>
props.signedOut
? messages.sharedInstanceSignedOutHeader
: messages.sharedInstanceWrongAccountHeader,
)
const bodyMessage = computed(() =>
props.role === 'owner'
? messages.sharedInstanceWrongAccountOwnerBody
: messages.sharedInstanceWrongAccountUserBody,
)
</script>
@@ -1,12 +0,0 @@
import type { StackedAdmonitionItem } from '@modrinth/ui'
export type InstanceAdmonitionKind =
| 'shared-instance-stale'
| 'shared-instance-unavailable'
| 'shared-instance-wrong-account'
export type InstanceAdmonitionItem = StackedAdmonitionItem & {
kind: InstanceAdmonitionKind
}
export type SharedInstanceRole = 'owner' | 'member'
@@ -4,14 +4,12 @@ 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'
@@ -19,8 +17,7 @@ import { useRouter } from 'vue-router'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { install_duplicate_instance } from '@/helpers/install'
import { edit, edit_icon, list, remove } from '@/helpers/instance'
import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { GameInstance } from '../../../helpers/types'
@@ -28,29 +25,21 @@ 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 duplicateInstance() {
await install_duplicate_instance(instance.value.id).catch(handleError)
async function duplicateProfile() {
await duplicate(instance.value.path).catch(handleError)
trackEvent('InstanceDuplicate', {
loader: instance.value.loader,
game_version: instance.value.game_version,
@@ -62,55 +51,9 @@ 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.id, null).catch(handleError)
await edit_icon(instance.value.path, null).catch(handleError)
trackEvent('InstanceRemoveIcon')
}
@@ -128,12 +71,12 @@ async function setIcon() {
if (!value) return
icon.value = value
await edit_icon(instance.value.id, icon.value).catch(handleError)
await edit_icon(instance.value.path, icon.value).catch(handleError)
trackEvent('InstanceSetIcon')
}
const editInstanceObject = computed(() => ({
const editProfileObject = computed(() => ({
name: title.value.trim().substring(0, 32) ?? 'Instance',
groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0),
}))
@@ -159,15 +102,15 @@ watch(
[title, groups, groups],
async () => {
if (removing.value) return
await edit(instance.value.id, editInstanceObject.value).catch(handleError)
await edit(instance.value.path, editProfileObject.value).catch(handleError)
},
{ deep: true },
)
const removing = ref(false)
async function removeInstance() {
async function removeProfile() {
removing.value = true
const path = instance.value.id
const path = instance.value.path
trackEvent('InstanceRemove', {
loader: instance.value.loader,
@@ -232,38 +175,6 @@ 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',
@@ -285,7 +196,7 @@ const messages = defineMessages({
</script>
<template>
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="removeInstance" />
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="removeProfile" />
<div class="block">
<div class="float-end ml-10 relative group w-fit">
<div class="flex flex-col gap-1">
@@ -311,7 +222,7 @@ const messages = defineMessages({
:src="icon ? convertFileSrc(icon) : icon"
size="108px"
class="transition-[filter] group-hover:brightness-75"
:tint-by="instance.id"
:tint-by="instance.path"
no-shadow
/>
<div
@@ -351,7 +262,7 @@ const messages = defineMessages({
aria-labelledby="duplicate-instance-label"
:disabled="installing"
class="w-max !shadow-none"
@click="duplicateInstance"
@click="duplicateProfile"
>
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
</button>
@@ -393,23 +304,6 @@ 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/instance'
import { edit } from '@/helpers/profile'
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 editInstanceObject = computed(() => {
const editInstancePatch: {
const editProfileObject = computed(() => {
const editProfile: {
hooks?: Hooks
} = {}
// When hooks are not overridden per-instance, we want to clear them
editInstancePatch.hooks = overrideHooks.value ? hooks.value : {}
editProfile.hooks = overrideHooks.value ? hooks.value : {}
return editInstancePatch
return editProfile
})
watch(
[overrideHooks, hooks],
async () => {
await edit(instance.value.id, editInstanceObject.value)
await edit(instance.value.path, editProfileObject.value)
},
{ deep: true },
)
@@ -4,206 +4,116 @@ 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 } from 'vue'
import { computed, ref, shallowRef } from 'vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache'
import { get_loader_versions } from '@/helpers/metadata'
import {
install_existing_instance,
install_pack_to_existing_instance,
wait_for_install_job,
} from '@/helpers/install'
import {
duplicate,
edit,
get_linked_modpack_info,
unlink_shared_instance,
install,
list,
update_managed_modrinth_version,
update_repair_modrinth,
} from '@/helpers/instance'
import { get_loader_versions } from '@/helpers/metadata'
} from '@/helpers/profile'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { injectInstanceSettings } from '@/providers/instance-settings'
import { useTheming } from '@/store/state'
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, closeModal } = injectInstanceSettings()
const managedContentPolicy = useManagedContentPolicy(instance)
const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
)
const { instance, offline, isMinecraftServer, onUnlinked } = injectInstanceSettings()
debug('metadata load: start', {
instanceId: instance.value.id,
loader: instance.value.loader,
gameVersion: instance.value.game_version,
installStage: instance.value.install_stage,
})
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),
])
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 { 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),
})
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' ||
(instance.value.link?.type === 'shared_instance' &&
!!instance.value.link.modpack_project_id &&
!!instance.value.link.modpack_version_id),
)
const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack')
const isSharedInstanceManagedModpack = managedContentPolicy.isManagedModpack
const canUnlinkSharedInstance = managedContentPolicy.canUnlink
const modpackInfoQuery = useQuery({
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
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)
const unlinkingSharedInstance = ref(false)
const installationSettingsBusy = computed(
() =>
instance.value.quarantined ||
instance.value.install_stage !== 'installed' ||
repairing.value ||
reinstalling.value ||
unlinkingSharedInstance.value ||
!!offline,
)
const installationSettingsBusyMessage = computed(() =>
instance.value.quarantined ? formatMessage(messages.locked) : null,
)
async function unlinkSharedInstance() {
unlinkingSharedInstance.value = true
try {
await unlink_shared_instance(instance.value.id)
await queryClient.invalidateQueries({ queryKey: ['sharedInstanceUsers', instance.value.id] })
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
handleError(error)
} finally {
unlinkingSharedInstance.value = false
}
}
const messages = defineMessages({
loaderVersion: {
id: 'instance.settings.tabs.installation.loader-version',
defaultMessage: '{loader} version',
},
locked: {
id: 'instance.settings.tabs.installation.locked',
defaultMessage: 'Installation settings are unavailable while this instance is locked.',
},
})
function getManifest(loader: string) {
const map: Record<string, Manifest | undefined> = {
fabric: fabricVersionsQuery.data.value,
forge: forgeVersionsQuery.data.value,
quilt: quiltVersionsQuery.data.value,
neoforge: neoforgeVersionsQuery.data.value,
const map: Record<string, typeof fabric_versions> = {
fabric: fabric_versions,
forge: forge_versions,
quilt: quilt_versions,
neoforge: neoforge_versions,
}
const manifest = map[loader]
debug('getManifest:', {
loader,
hasManifest: !!manifest,
gameVersions: manifest?.gameVersions?.length ?? 0,
})
return manifest
return map[loader]
}
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
}
provideInstanceBackup(instance)
provideAppBackup({
async createBackup() {
const allProfiles = await list()
const prefix = `${instance.value.name} - Backup #`
const existingNums = allProfiles
.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}` })
},
})
provideInstallationSettings({
closeSettings: closeModal,
loading: computed(() => metadataLoading.value || modpackInfoQuery.isLoading.value),
loading: ref(false),
installationInfo: computed(() => {
const rows = [
{
@@ -225,24 +135,15 @@ provideInstallationSettings({
}
return rows
}),
isLinked: computed(
isLinked: computed(() => !!instance.value.linked_data?.locked),
isBusy: computed(
() =>
isModrinthLinkedModpack.value ||
isImportedModpack.value ||
isSharedInstanceManagedModpack.value,
instance.value.install_stage !== 'installed' ||
repairing.value ||
reinstalling.value ||
!!offline,
),
isBusy: installationSettingsBusy,
busyMessage: installationSettingsBusyMessage,
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,
@@ -254,212 +155,114 @@ provideInstallationSettings({
currentPlatform: computed(() => instance.value.loader),
currentGameVersion: computed(() => instance.value.game_version),
currentLoaderVersion: computed(() => instance.value.loader_version ?? ''),
availablePlatforms: computed(() => loadersQuery.data.value?.map((x) => x.name) ?? []),
availablePlatforms: loaders?.value?.map((x) => x.name) ?? [],
resolveGameVersions(loader, showSnapshots) {
const versions = gameVersionsQuery.data.value ?? []
const versions = all_game_versions?.value ?? []
const filtered = versions.filter((item) => {
if (loader === 'vanilla') return true
const manifest = getManifest(loader)
return !!manifest?.gameVersions?.some((x) => item.version === x.id)
return !!manifest?.value?.gameVersions?.some((x) => item.version === x.id)
})
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
return (showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')).map(
(x) => ({ value: x.version, label: x.version }),
)
},
resolveLoaderVersions(loader, gameVersion) {
if (loader === 'vanilla' || !gameVersion) {
debug('resolveLoaderVersions: skipped', { loader, gameVersion })
return []
}
if (loader === 'vanilla' || !gameVersion) return []
const manifest = getManifest(loader)
if (!manifest) {
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
return []
if (!manifest?.value) return []
if (loader === 'fabric' || loader === 'quilt') {
return manifest.value.gameVersions[0]?.loaders ?? []
}
const entry = manifest.gameVersions?.find((item) => item.id === gameVersion)
if (entry?.versionGroup) {
const result =
manifest.versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? []
debug('resolveLoaderVersions: version group result', {
loader,
gameVersion,
versionGroup: entry.versionGroup,
count: result.length,
})
return result
}
const placeholder = manifest.gameVersions?.find((item) => item.id === '${modrinth.gameVersion}')
if (placeholder) {
const result = manifest.gameVersions?.some((item) => item.id === gameVersion)
? placeholder.loaders
: []
debug('resolveLoaderVersions: placeholder result', {
loader,
gameVersion,
count: result.length,
})
return result
}
const result = entry?.loaders ?? []
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
return result
return manifest.value.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
},
resolveHasSnapshots(loader) {
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 versions = all_game_versions?.value ?? []
if (loader === 'vanilla') return versions.some((x) => x.version_type !== 'release')
const manifest = getManifest(loader)
const supported = versions.filter(
(item) => !!manifest?.gameVersions?.some((x) => item.version === x.id),
(item) => !!manifest?.value?.gameVersions?.some((x) => item.version === x.id),
)
const result = supported.some((x) => x.version_type !== 'release')
debug('resolveHasSnapshots:', {
loader,
totalVersions: versions.length,
supportedVersions: supported.length,
result,
})
return result
return supported.some((x) => x.version_type !== 'release')
},
async save(platform, gameVersion, loaderVersionId) {
debug('save: called', {
instanceId: instance.value.id,
platform,
gameVersion,
loaderVersionId,
})
const editInstancePatch: Record<string, string | undefined> = {
const editProfile: Record<string, string | undefined> = {
loader: platform,
game_version: gameVersion,
}
if (platform !== 'vanilla' && loaderVersionId) {
editInstancePatch.loader_version = loaderVersionId
editProfile.loader_version = loaderVersionId
}
await edit(instance.value.id, editInstancePatch).catch(handleError)
debug('save: edit complete', { editInstancePatch })
await edit(instance.value.path, editProfile).catch(handleError)
},
afterSave: async () => {
debug('afterSave: installing', { instanceId: instance.value.id })
await install_existing_instance(instance.value.id, false).catch(handleError)
await install(instance.value.path, 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_existing_instance(instance.value.id, true).catch(handleError)
await install(instance.value.path, 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
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')
await update_repair_modrinth(instance.value.path).catch(handleError)
reinstalling.value = false
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
},
async unlinkModpack() {
debug('unlinkModpack: called', { instanceId: instance.value.id })
await edit(instance.value.id, {
link: null as unknown as undefined,
await edit(instance.value.path, {
linked_data: null as unknown as undefined,
})
await queryClient.invalidateQueries({
queryKey: ['linkedModpackInfo', instance.value.id],
queryKey: ['linkedModpackInfo', instance.value.path],
})
onUnlinked()
debug('unlinkModpack: done')
},
getCachedModpackVersions: () => null,
async fetchModpackVersions() {
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 })
const versions = await get_project_versions(instance.value.linked_data!.project_id!).catch(
handleError,
)
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) {
debug('onModpackVersionConfirm: called', {
versionId: version.id,
instanceId: instance.value.id,
})
await update_managed_modrinth_version(instance.value.id, version.id)
await update_managed_modrinth_version(instance.value.path, version.id)
await queryClient.invalidateQueries({
queryKey: ['linkedModpackInfo', instance.value.id],
queryKey: ['linkedModpackInfo', instance.value.path],
})
debug('onModpackVersionConfirm: done')
},
updaterModalProps: computed(() => ({
isApp: true,
currentVersionId: modpackInfo.value?.update_version_id ?? instance.value.link?.version_id ?? '',
currentVersionId:
modpackInfo.value?.update_version_id ?? instance.value.linked_data?.version_id ?? '',
projectIconUrl: modpackInfo.value?.project?.icon_url,
projectName: modpackInfo.value?.project?.title ?? 'Modpack',
currentGameVersion: instance.value.game_version,
@@ -468,29 +271,12 @@ provideInstallationSettings({
isServer: false,
isApp: true,
showModpackVersionActions: computed(
() =>
isModrinthLinkedModpack.value &&
!isMinecraftServer.value &&
!isSharedInstanceManagedModpack.value,
),
isLocalFile: isImportedModpack,
isManagedModpack: isSharedInstanceManagedModpack,
managedModpackWarning: managedContentPolicy.managedModpackWarning,
showModpackVersionActions: !isMinecraftServer.value,
repairing,
reinstalling,
})
</script>
<template>
<InstallationSettingsLayout>
<template #extra>
<SharedInstanceInstallationSettingsControls
:can-unlink="canUnlinkSharedInstance"
:busy="installationSettingsBusy"
:unlinking="unlinkingSharedInstance"
:unlink="unlinkSharedInstance"
/>
</template>
</InstallationSettingsLayout>
<InstallationSettingsLayout />
</template>
@@ -1,15 +1,6 @@
<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,
@@ -17,13 +8,11 @@ import {
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { computed, readonly, ref, watch } from 'vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import useJavaTest from '@/composables/useJavaTest'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { edit, get_optimal_jre_key } from '@/helpers/instance'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import { injectInstanceSettings } from '@/providers/instance-settings'
@@ -36,54 +25,9 @@ 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 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 optimalJava = readonly(await get_optimal_jre_key(instance.value.path).catch(handleError))
const javaInstall = ref({ path: optimalJava.path ?? instance.value.java_path })
const overrideJavaArgs = ref((instance.value.extra_launch_args?.length ?? 0) > 0)
const javaArgs = ref(
@@ -104,11 +48,11 @@ const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) a
snapPoints: number[]
}
const editInstanceObject = computed(() => {
const editProfileObject = computed(() => {
return {
java_path:
overrideJavaInstall.value && javaPath.value
? javaPath.value.replace('java.exe', 'javaw.exe')
overrideJavaInstall.value && javaInstall.value.path !== ''
? javaInstall.value.path.replace('java.exe', 'javaw.exe')
: null,
extra_launch_args: overrideJavaArgs.value
? javaArgs.value.trim().split(/\s+/).filter(Boolean)
@@ -127,7 +71,7 @@ const editInstanceObject = computed(() => {
watch(
[
overrideJavaInstall,
javaPath,
javaInstall,
overrideJavaArgs,
javaArgs,
overrideEnvVars,
@@ -136,7 +80,7 @@ watch(
memory,
],
async () => {
await edit(instance.value.id, editInstanceObject.value)
await edit(instance.value.path, editProfileObject.value)
},
{ deep: true },
)
@@ -146,45 +90,17 @@ 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',
},
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...',
javaMemory: {
id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated',
},
hooks: {
id: 'instance.settings.tabs.java.hooks',
@@ -195,86 +111,43 @@ const messages = defineMessages({
<template>
<div>
<JavaDetectionModal ref="javaDetectionModal" @submit="(val) => (javaPath = val.path)" />
<h2 class="m-0 mb-2 text-lg font-extrabold text-contrast block">
<h2 id="project-name" class="m-0 mb-2.5 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.javaInstallation) }}
</h2>
<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
<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
>
<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>
</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>
</div>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
<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">
{{ formatMessage(messages.javaMemory) }}
</h2>
<Checkbox
v-model="overrideMemorySettings"
:label="formatMessage(messages.customMemoryAllocation)"
class="mb-2"
/>
<Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2.5" />
<Slider
id="max-memory"
v-model="memory.maximum"
@@ -286,36 +159,28 @@ const messages = defineMessages({
:snap-range="512"
unit="MB"
/>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.javaArguments) }}
</h2>
<Checkbox
v-model="overrideJavaArgs"
:label="formatMessage(messages.customJavaArguments)"
class="my-2"
/>
<Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" />
<StyledInput
id="java-args"
v-model="javaArgs"
autocomplete="off"
:disabled="!overrideJavaArgs"
:placeholder="formatMessage(messages.enterJavaArguments)"
placeholder="Enter java arguments..."
wrapper-class="w-full"
/>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.javaEnvironmentVariables) }}
</h2>
<Checkbox
v-model="overrideEnvVars"
:label="formatMessage(messages.customEnvironmentVariables)"
class="mb-2"
/>
<Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2.5" />
<StyledInput
id="env-vars"
v-model="envVars"
autocomplete="off"
:disabled="!overrideEnvVars"
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
placeholder="Enter environmental variables..."
wrapper-class="w-full"
/>
</div>
@@ -1,42 +0,0 @@
<template>
<div>
<SharedInstanceInstallationSettingsControls
can-unpublish
:busy="isBusy"
:unpublishing="unpublishing"
:unpublish="unpublishSharedInstance"
/>
</div>
</template>
<script setup lang="ts">
import { injectNotificationManager } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { unpublish_shared_instance } from '@/helpers/instance'
import { injectInstanceSettings } from '@/providers/instance-settings'
const { instance, offline, onUnlinked } = injectInstanceSettings()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const unpublishing = ref(false)
const isBusy = computed(
() => instance.value.install_stage !== 'installed' || unpublishing.value || !!offline,
)
async function unpublishSharedInstance() {
unpublishing.value = true
try {
await unpublish_shared_instance(instance.value.id)
queryClient.setQueryData(['sharedInstanceUsers', instance.value.id], [])
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
handleError(error)
} finally {
unpublishing.value = false
}
}
</script>
@@ -9,7 +9,7 @@ import {
} from '@modrinth/ui'
import { computed, type Ref, ref, watch } from 'vue'
import { edit } from '@/helpers/instance'
import { edit } from '@/helpers/profile'
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 editInstanceObject = computed(() => {
const editProfileObject = computed(() => {
if (!overrideWindowSettings.value) {
return {
force_fullscreen: null,
@@ -48,7 +48,7 @@ const editInstanceObject = computed(() => {
watch(
[overrideWindowSettings, resolution, fullscreenSetting],
async () => {
await edit(instance.value.id, editInstanceObject.value)
await edit(instance.value.path, editProfileObject.value)
},
{ deep: true },
)
@@ -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 { findMinecraftAuthError, type MinecraftAuthError } from './minecraft-auth-errors'
import { type MinecraftAuthError, minecraftAuthErrors } 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 = findMinecraftAuthError(rawError.value)
matchedError.value = minecraftAuthErrors.find((e) => rawError.value.includes(e.errorCode)) ?? null
debugCollapsed.value = true
hide_ads_window()
@@ -1,93 +1,10 @@
export interface MinecraftAuthError {
errorCode?: string
errorMatchers?: string[]
matches?: (message: string) => boolean
errorCode: string
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:
@@ -170,31 +87,4 @@ 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
)
}
@@ -1,144 +0,0 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" max-width="544px" no-padding>
<div class="grid grid-cols-[1fr_auto] gap-2.5 h-[154px] px-7 pt-4 pb-1 pr-9">
<div class="flex flex-col gap-2.5 items-start justify-center h-min mt-5">
<div class="font-semibold text-xl text-contrast">
{{ formatMessage(messages.descriptionHeader) }}
</div>
<div class="text-secondary leading-6">
{{ formatMessage(messages.description) }}
</div>
</div>
<div class="relative h-full w-[96px] overflow-hidden mx-3">
<div class="absolute top-0 left-0 z-0 w-full flex grow-0 flex-col items-end p-0">
<img :src="steveImage" alt="" class="self-stretch" />
</div>
<div
class="absolute left-0 bottom-0 z-10 order-1 h-6 w-[120px] shrink-0 grow-0 bg-[linear-gradient(180deg,rgba(39,41,46,0)_0%,#27292E_80%,#27292E_100%)]"
></div>
</div>
</div>
<div class="flex flex-col gap-6 px-6 pb-6">
<div class="flex justify-end gap-2">
<ButtonStyled>
<a class="w-full !shadow-none" href="https://support.modrinth.com" @click="modal?.hide()">
<MessagesSquareIcon />
{{ formatMessage(messages.getSupport) }}
</a>
</ButtonStyled>
<ButtonStyled color="brand">
<button class="w-full !shadow-none" :disabled="loadingSignIn" @click="signIn">
<SpinnerIcon v-if="loadingSignIn" class="animate-spin" />
<svg
v-else
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect x="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect y="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect
x="10.75"
y="10.75"
width="9.25"
height="9.25"
fill="black"
fill-opacity="0.9"
/>
</svg>
{{ formatMessage(messages.signIn) }}
</button>
</ButtonStyled>
</div>
<p class="m-0 text-center text-sm text-secondary">
{{ formatMessage(messages.dontHaveAccount) }}
<a
class="text-blue font-medium hover:underline"
href="https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc"
>
{{ formatMessage(messages.getMinecraft) }}
</a>
</p>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { MessagesSquareIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { inject, type Ref, ref } from 'vue'
import steveImage from '@/assets/steve-look-up-left.webp'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import { trackEvent } from '@/helpers/analytics'
import { login as loginFlow, set_default_user } from '@/helpers/auth.js'
import { handleSevereError } from '@/store/error.js'
const { formatMessage } = useVIntl()
const accountsCard = inject('accountsCard') as Ref<InstanceType<typeof AccountsCard> | null>
const messages = defineMessages({
header: {
id: 'minecraft-required.header',
defaultMessage: 'Minecraft required',
},
descriptionHeader: {
id: 'minecraft-required.description-header',
defaultMessage: 'Sign in to a Microsoft account',
},
description: {
id: 'minecraft-required.description',
defaultMessage:
'You need a Microsoft account that owns Minecraft before you can launch and play.',
},
getSupport: {
id: 'minecraft-required.get-support',
defaultMessage: 'Get support',
},
signIn: {
id: 'minecraft-required.sign-in',
defaultMessage: 'Sign in to Microsoft',
},
dontHaveAccount: {
id: 'minecraft-required.dont-have-account',
defaultMessage: 'Dont have an account?',
},
getMinecraft: {
id: 'minecraft-required.get-minecraft',
defaultMessage: 'Get Minecraft',
},
})
const modal = ref<InstanceType<typeof NewModal>>()
const loadingSignIn = ref(false)
function show() {
modal.value?.show()
}
async function signIn() {
loadingSignIn.value = true
try {
const loggedIn = await loginFlow()
if (!loggedIn) return
await set_default_user(loggedIn.profile.id)
await accountsCard.value?.refreshValues()
await trackEvent('AccountLogIn', { source: 'MinecraftRequiredModal' })
modal.value?.hide()
} catch (error) {
handleSevereError(error)
} finally {
loadingSignIn.value = false
}
}
defineExpose({
show,
})
</script>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
defineProps({
onFlowCancel: {
type: Function,
default() {
return async () => {}
},
},
})
const modal = ref()
function show() {
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show, hide })
</script>
<template>
<ModalWrapper ref="modal" @hide="onFlowCancel">
<template #title>
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
<LogInIcon /> Sign in
</span>
</template>
<div class="flex justify-center gap-2">
<SpinnerIcon class="w-12 h-12 animate-spin" />
</div>
<p class="text-sm text-secondary">
Please sign in at the browser window that just opened to continue.
</p>
</ModalWrapper>
</template>
@@ -1,22 +1,15 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.installToPlay)"
:closable="true"
:on-hide="show_ads_window"
max-width="544px"
width="544px"
>
<div v-if="requiredContentProject" class="flex w-full flex-col gap-6">
<p class="m-0 text-primary">
{{ formatMessage(messages.inviteWarning) }}
</p>
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
<div v-if="requiredContentProject" class="flex flex-col gap-6 max-w-[500px]">
<Admonition type="info" :header="formatMessage(messages.contentRequired)">
{{ formatMessage(messages.serverRequiresMods) }}
</Admonition>
<div class="flex flex-col gap-2.5">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.sharedInstance) }}
</span>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<span class="font-semibold text-contrast">{{
formatMessage(messages.requiredModpack)
}}</span>
<ButtonStyled type="transparent">
<button @click="openViewContents">
@@ -26,16 +19,14 @@
</ButtonStyled>
</div>
<div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3">
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
<Avatar
:src="requiredContentProject.icon_url"
:alt="requiredContentProject.title"
size="56px"
no-shadow
class="!rounded-2xl"
size="48px"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="truncate font-semibold text-contrast">
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-contrast">
<template v-if="usingCustomModpack && modpackVersion">
{{ modpackVersion.name }}
</template>
@@ -43,7 +34,7 @@
{{ requiredContentProject.title }}
</template>
</span>
<span class="truncate text-sm font-medium text-secondary">
<span class="text-sm text-secondary">
{{ loaderDisplay }} {{ requiredContentProject.game_versions?.[0] }}
<template v-if="modCount">
· {{ formatMessage(messages.modCount, { count: modCount }) }}
@@ -52,105 +43,24 @@
</div>
</div>
</div>
</div>
<Admonition
v-if="hasExternalFiles"
type="warning"
:header="formatMessage(messages.unknownFilesWarning)"
>
{{ formatMessage(messages.unknownFilesDescription) }}
</Admonition>
<div v-if="hasExternalFiles" class="relative w-full">
<div
ref="externalFileTable"
class="max-h-[242px] overflow-y-auto rounded-2xl"
@scroll="checkTableScrollState"
>
<Table
:columns="externalFileColumns"
:data="externalFileRows"
row-key="id"
virtualized
:virtual-row-height="48"
class="shadow-sm"
>
<template #cell-name="{ value }">
<span class="block truncate" :title="String(value)">{{ value }}</span>
</template>
</Table>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTableTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTableBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
<p v-if="hasExternalFiles" class="m-0 text-primary">
{{ formatMessage(messages.reviewedFiles) }}
</p>
<div class="flex w-full items-center justify-between gap-2">
<ButtonStyled type="transparent" color="red">
<button @click="handleReport">
<ReportIcon />
{{ formatMessage(commonMessages.reportButton) }}
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button @click="handleDecline">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleAccept">
<DownloadIcon />
{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
<div class="flex items-center gap-2">
<template v-if="hasExternalFiles">
<ButtonStyled type="transparent" color="orange">
<button @click="handleAccept">
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleDecline">
<BanIcon />
{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled>
<button @click="handleDecline">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleAccept">
<DownloadIcon />
{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
</template>
</div>
</div>
</div>
</template>
</NewModal>
<ModpackContentModal
@@ -162,56 +72,33 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { BanIcon, DownloadIcon, EyeIcon, ReportIcon, XIcon } from '@modrinth/assets'
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
import type { ContentItem } from '@modrinth/ui'
import {
Admonition,
Avatar,
ButtonStyled,
commonMessages,
type ContentItem,
defineMessages,
formatLoader,
ModpackContentModal,
NewModal,
Table,
type TableColumn,
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, ref } from 'vue'
import { computed, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { injectServerInstall } from '@/providers/server-install'
type ExternalFileColumn = 'name'
type ExternalFileRow = {
id: string
name: string
}
const modal = ref<InstanceType<typeof NewModal>>()
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const project = ref<Labrinth.Projects.v3.Project | null>(null)
const requiredContentProject = ref<Labrinth.Projects.v2.Project | null>(null)
const externalFiles = ref<string[]>([])
const externalFileTable = ref<HTMLElement | null>(null)
const onInstallComplete = ref<() => void>(() => {})
const { formatMessage } = useVIntl()
const props = defineProps<{
showExternalWarnings?: boolean
}>()
const { installServerProject, startInstallingServer, stopInstallingServer } = injectServerInstall()
const {
showTopFade: showTableTopFade,
showBottomFade: showTableBottomFade,
checkScrollState: checkTableScrollState,
forceCheck: forceCheckTableScroll,
} = useScrollIndicator(externalFileTable)
const usingCustomModpack = computed(() => {
return requiredContentProject.value?.id === project.value?.id
@@ -224,36 +111,13 @@ const loaderDisplay = computed(() => {
})
const modCount = computed(() => modpackVersion.value?.dependencies?.length)
const hasExternalFiles = computed(
() => Boolean(props.showExternalWarnings) && externalFiles.value.length > 0,
)
const externalFileRows = computed<ExternalFileRow[]>(() =>
externalFiles.value.map((name, index) => ({
id: `${index}-${name}`,
name,
})),
)
async function fetchData(versionId: string) {
// cache is making version null for some reason so bypassing for now
const version = await get_version(versionId, 'bypass')
modpackVersion.value = version
modpackVersion.value = await get_version(versionId, 'bypass')
if (version?.project_id) {
requiredContentProject.value = await get_project(version.project_id, 'bypass')
externalFiles.value = [
...new Set(
(version.dependencies ?? [])
.filter(
(dependency) =>
dependency.dependency_type === 'embedded' &&
!dependency.project_id &&
!dependency.version_id &&
dependency.file_name,
)
.flatMap((dependency) => (dependency.file_name ? [dependency.file_name] : [])),
),
].sort((left, right) => left.localeCompare(right))
if (modpackVersion.value?.project_id) {
requiredContentProject.value = await get_project(modpackVersion.value.project_id, 'bypass')
}
}
@@ -275,12 +139,6 @@ function handleDecline() {
hide()
}
function handleReport() {
if (project.value?.id) {
openUrl(`https://modrinth.com/report?item=project&itemID=${project.value.id}`)
}
}
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
async function openViewContents() {
@@ -359,19 +217,17 @@ async function show(
modpackVersionId.value = modpackVersionIdVal
modpackVersion.value = null
requiredContentProject.value = null
externalFiles.value = []
onInstallComplete.value = callback
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
hide_ads_window()
modal.value?.show(e)
await nextTick()
forceCheckTableScroll()
}
function hide() {
modal.value?.hide()
show_ads_window()
}
const messages = defineMessages({
@@ -379,10 +235,22 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.header',
defaultMessage: 'Install to play',
},
inviteWarning: {
id: 'app.modal.install-to-play.invite-warning',
sharedServerInstance: {
id: 'app.modal.install-to-play.shared-server-instance',
defaultMessage: 'Shared server instance',
},
contentRequired: {
id: 'app.modal.install-to-play.content-required',
defaultMessage: 'Content required',
},
serverRequiresMods: {
id: 'app.modal.install-to-play.server-requires-mods',
defaultMessage:
'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
'This server requires mods to play. Click Install to set up the required files from Modrinth, then launch directly into the server.',
},
requiredModpack: {
id: 'app.modal.install-to-play.required-modpack',
defaultMessage: 'Required modpack',
},
sharedInstance: {
id: 'app.modal.install-to-play.shared-instance',
@@ -400,41 +268,7 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.view-contents',
defaultMessage: 'View contents',
},
unknownFilesWarning: {
id: 'app.modal.install-to-play.unknown-files-warning',
defaultMessage: 'Unknown files warning',
},
unknownFilesDescription: {
id: 'app.modal.install-to-play.unknown-files-description',
defaultMessage:
'This server modpack contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust.',
},
unrecognizedFiles: {
id: 'app.modal.install-to-play.unrecognized-files',
defaultMessage: 'Unrecognized files',
},
reviewedFiles: {
id: 'app.modal.install-to-play.reviewed-files',
defaultMessage:
'A file is only reviewed if its published to Modrinth, regardless of its file format (including .mrpack).',
},
installAnyway: {
id: 'app.modal.install-to-play.install-anyway',
defaultMessage: 'Install anyway',
},
dontInstall: {
id: 'app.modal.install-to-play.external-files-dont-install',
defaultMessage: "Don't install",
},
})
const externalFileColumns = computed<TableColumn<ExternalFileColumn>[]>(() => [
{
key: 'name',
label: formatMessage(messages.unrecognizedFiles),
cellClass: '!h-12',
},
])
defineExpose({ show, hide })
</script>
@@ -14,7 +14,7 @@ defineProps<{
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
size="24px"
:tint-by="instance.id"
:tint-by="instance.path"
/>
{{ instance.name }} <ChevronRightIcon />
</span>
@@ -6,7 +6,6 @@ import {
CoffeeIcon,
InfoIcon,
MonitorIcon,
UsersIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
@@ -17,8 +16,7 @@ import {
type TabbedModalTab,
useVIntl,
} from '@modrinth/ui'
import type { PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, nextTick, ref, watch } from 'vue'
@@ -26,18 +24,14 @@ import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.v
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import SharingSettings from '@/components/ui/instance_settings/SharingSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import { get_project_v3 } from '@/helpers/cache'
import { get_linked_modpack_info } from '@/helpers/instance'
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { get_linked_modpack_info } from '@/helpers/profile'
import { provideInstanceSettings } from '@/providers/instance-settings'
import type { GameInstance } from '../../../helpers/types'
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const props = defineProps<{
instance: GameInstance
@@ -51,26 +45,20 @@ 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.link?.project_id) {
get_project_v3(instance.link.project_id, 'must_revalidate')
if (instance.linked_data?.project_id) {
get_project_v3(instance.linked_data.project_id, 'must_revalidate')
.then((project: Labrinth.Projects.v3.Project | undefined) => {
if (project?.minecraft_server != null) {
isMinecraftServer.value = true
@@ -99,15 +87,6 @@ const tabs = computed<TabbedModalTab[]>(() => [
icon: WrenchIcon,
content: InstallationSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.sharing',
defaultMessage: 'Sharing',
}),
icon: UsersIcon,
content: SharingSettings,
shown: props.instance.shared_instance?.role === 'owner' && !props.instance.quarantined,
},
{
name: defineMessage({
id: 'instance.settings.tabs.window',
@@ -134,50 +113,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
},
])
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),
})
const queryClient = useQueryClient()
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
function show(tabIndex?: number) {
if (props.instance.link?.project_id) {
if (props.instance.linked_data?.project_id) {
queryClient.prefetchQuery({
queryKey: ['linkedModpackInfo', props.instance.id],
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
queryKey: ['linkedModpackInfo', props.instance.path],
queryFn: () => get_linked_modpack_info(props.instance.path, 'stale_while_revalidate'),
})
}
tabbedModal.value?.show()
@@ -186,7 +129,7 @@ function show(tabIndex?: number) {
}
}
defineExpose({ show, hide })
defineExpose({ show })
</script>
<template>
<TabbedModal
@@ -200,7 +143,7 @@ defineExpose({ show, hide })
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
size="24px"
:tint-by="props.instance.id"
:tint-by="props.instance.path"
/>
{{ instance.name }} <ChevronRightIcon />
<span class="font-extrabold text-contrast">{{
@@ -68,17 +68,17 @@ const messages = defineMessages({
})
const emit = defineEmits<{
(e: 'go-to-instance', instanceId: string): void
(e: 'go-to-instance', instancePath: string): void
(e: 'create-anyway'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const instanceName = ref('')
const instanceId = ref('')
const instancePath = ref('')
function show(name: string, id: string) {
function show(name: string, path: string) {
instanceName.value = name
instanceId.value = id
instancePath.value = path
modal.value?.show()
}
@@ -88,7 +88,7 @@ function handleCancel() {
function handleGoToInstance() {
modal.value?.hide()
emit('go-to-instance', instanceId.value)
emit('go-to-instance', instancePath.value)
}
function handleCreateAnyway() {
@@ -1,265 +0,0 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(authenticating ? messages.signingInHeader : messages.header)"
:on-hide="handleHide"
no-padding
max-width="548px"
width="100%"
>
<div v-if="authenticating === null" class="flex w-full flex-col gap-6 p-6">
<div class="flex flex-col gap-2 px-3">
<h2 class="m-0 text-xl font-semibold leading-7 text-contrast">
{{ formatMessage(messages.signInHeading) }}
</h2>
<p class="m-0 text-base leading-6 text-primary">
{{ formatMessage(messages.description) }}
</p>
</div>
<div class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled>
<button class="w-full !shadow-none" type="button" @click="authenticate('sign-up')">
<UserPlusIcon aria-hidden="true" />
{{ formatMessage(messages.createAccountButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button class="w-full" type="button" @click="authenticate('sign-in')">
<LogInIcon aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
</div>
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
<IntlFormatted :message-id="messages.supportPrompt">
<template #support="{ children }">
<button
type="button"
class="inline cursor-pointer border-0 bg-transparent p-0 text-base font-medium leading-6 text-blue hover:underline"
@click="openSupport"
>
<component :is="() => children" />
</button>
</template>
</IntlFormatted>
</p>
</div>
</div>
<div v-else class="flex w-full flex-col gap-6 p-6">
<div class="flex flex-col gap-2.5 px-3">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-xl font-semibold leading-7 text-contrast">
{{ formatMessage(messages.continueInBrowserHeading) }}
</h2>
<p class="m-0 text-base leading-6 text-primary">
{{ formatMessage(messages.browserDescription) }}
</p>
</div>
<div class="flex items-center gap-1.5 text-primary">
<SpinnerIcon aria-hidden="true" class="h-5 w-5 shrink-0 animate-spin" />
<span class="text-base leading-6">
{{ formatMessage(messages.waitingForBrowser) }}
</span>
</div>
</div>
<div class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled type="outlined">
<button class="w-full" type="button" @click="modal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button
class="w-full !shadow-none"
type="button"
:disabled="reopeningBrowser"
@click="reopenBrowser"
>
<RefreshCwIcon aria-hidden="true" />
{{ formatMessage(messages.openBrowserAgainButton) }}
</button>
</ButtonStyled>
</div>
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
<IntlFormatted :message-id="messages.supportPrompt">
<template #support="{ children }">
<button
type="button"
class="inline cursor-pointer border-0 bg-transparent p-0 text-base font-medium leading-6 text-blue hover:underline"
@click="openSupport"
>
<component :is="() => children" />
</button>
</template>
</IntlFormatted>
</p>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { LogInIcon, RefreshCwIcon, SpinnerIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, IntlFormatted, NewModal, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue'
import { cancelLogin, type ModrinthAuthFlow } from '@/helpers/mr_auth'
const props = defineProps<{
requestAuth: (flow: ModrinthAuthFlow) => Promise<boolean>
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const authenticating = ref<ModrinthAuthFlow | null>(null)
const reopeningBrowser = ref(false)
let resolveShow: ((signedIn: boolean) => void) | undefined
let authenticationId = 0
let activeAuthentication: Promise<void> | undefined
function show(event?: MouseEvent) {
resetAuthentication(true)
resolveShow?.(false)
const modalInstance = modal.value
if (!modalInstance) return Promise.resolve(false)
return new Promise<boolean>((resolve) => {
resolveShow = resolve
modalInstance.show(event)
})
}
function showSigningIn(flow: ModrinthAuthFlow = 'sign-in', event?: MouseEvent) {
const result = show(event)
authenticate(flow)
return result
}
function finish(signedIn: boolean) {
resolveShow?.(signedIn)
resolveShow = undefined
}
function authenticate(flow: ModrinthAuthFlow) {
const id = ++authenticationId
authenticating.value = flow
const authentication = (async () => {
try {
if ((await props.requestAuth(flow)) && authenticationId === id) {
authenticating.value = null
activeAuthentication = undefined
finish(true)
modal.value?.hide()
}
} finally {
if (authenticationId === id) {
authenticating.value = null
activeAuthentication = undefined
}
}
})()
activeAuthentication = authentication
}
async function reopenBrowser() {
const flow = authenticating.value
if (!flow || reopeningBrowser.value) return
reopeningBrowser.value = true
const previousAuthentication = activeAuthentication
++authenticationId
try {
await cancelLogin()
await previousAuthentication?.catch(() => undefined)
if (authenticating.value === flow) authenticate(flow)
} finally {
reopeningBrowser.value = false
}
}
function resetAuthentication(cancelActive: boolean) {
const wasAuthenticating = authenticating.value !== null
++authenticationId
activeAuthentication = undefined
authenticating.value = null
reopeningBrowser.value = false
if (cancelActive && wasAuthenticating) void cancelLogin()
}
function handleHide() {
resetAuthentication(true)
finish(false)
}
function openSupport() {
openUrl('https://support.modrinth.com')
}
const messages = defineMessages({
header: {
id: 'modal.modrinth-account-required.header',
defaultMessage: 'Account required',
},
signingInHeader: {
id: 'modal.modrinth-account-required.signing-in-header',
defaultMessage: 'Signing in',
},
signInHeading: {
id: 'modal.modrinth-account-required.sign-in-heading',
defaultMessage: 'Sign in to a Modrinth account',
},
description: {
id: 'modal.modrinth-account-required.description',
defaultMessage:
"You'll need to sign into your Modrinth account before you can use this feature.",
},
createAccountButton: {
id: 'modal.modrinth-account-required.create-account-button',
defaultMessage: 'Create an account',
},
signInButton: {
id: 'modal.modrinth-account-required.sign-in-button',
defaultMessage: 'Sign in to Modrinth',
},
continueInBrowserHeading: {
id: 'modal.modrinth-account-required.continue-in-browser-heading',
defaultMessage: 'Continue in your browser',
},
browserDescription: {
id: 'modal.modrinth-account-required.browser-description',
defaultMessage:
'A new tab opened to sign in. Complete the sign in there, then return to the app.',
},
waitingForBrowser: {
id: 'modal.modrinth-account-required.waiting-for-browser',
defaultMessage: 'Waiting for browser confirmation...',
},
cancelButton: {
id: 'modal.modrinth-account-required.cancel-button',
defaultMessage: 'Cancel',
},
openBrowserAgainButton: {
id: 'modal.modrinth-account-required.open-browser-again-button',
defaultMessage: 'Open browser again',
},
supportPrompt: {
id: 'modal.modrinth-account-required.support-prompt',
defaultMessage: 'Having trouble signing in? <support>Get support</support>',
},
})
defineExpose({ show, showSigningIn })
</script>
@@ -2,18 +2,17 @@
<ContentDiffModal
ref="diffModal"
:header="formatMessage(messages.updateToPlay)"
:admonition-header="formatMessage(messages.updateRequired)"
:description="
instance ? formatMessage(messages.updateRequiredDescription, { name: instance.name }) : ''
"
:diffs="normalizedDiffs"
:version-date="versionDate"
:show-external-warnings="showExternalWarnings"
:external-warning-description="formatMessage(messages.externalWarningDescription)"
:confirm-label="formatMessage(commonMessages.updateButton)"
:confirm-icon="DownloadIcon"
:removed-label="formatMessage(messages.removed)"
:show-report-button="true"
@confirm="handleUpdate"
@cancel="handleDecline"
@report="handleReport"
/>
</template>
@@ -27,12 +26,12 @@ import {
defineMessages,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { wait_for_install_job } from '@/helpers/install'
import { update_managed_modrinth_version } from '@/helpers/instance'
import { update_managed_modrinth_version } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { injectServerInstall } from '@/providers/server-install'
@@ -77,15 +76,6 @@ const { formatMessage } = useVIntl()
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise<void>
const { showExternalWarnings } = defineProps<{
showExternalWarnings?: boolean
}>()
const emit = defineEmits<{
cancel: []
complete: []
}>()
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
@@ -93,21 +83,14 @@ const diffs = ref<DependencyDiff[]>([])
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Version | null>(null)
const normalizedDiffs = computed<ContentDiffItem[]>(() => {
return diffs.value.map((diff) => ({
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
diffs.value.map((diff) => ({
type: diff.type,
external: Boolean(diff.fileName && !diff.project),
projectName: diff.project?.title,
fileName: diff.fileName,
currentVersionName: diff.currentVersion?.version_number,
newVersionName: diff.newVersion?.version_number,
}))
})
const versionDate = computed(() =>
modpackVersion.value?.date_published
? dayjs(modpackVersion.value.date_published).format('MMMM D, YYYY')
: undefined,
})),
)
async function computeDependencyDiffs(
@@ -206,10 +189,6 @@ async function computeDependencyDiffs(
}
})
.sort((a, b) => {
const aExternal = Boolean(a.fileName && !a.project)
const bExternal = Boolean(b.fileName && !b.project)
if (aExternal !== bExternal) return aExternal ? -1 : 1
const typeOrder = { added: 0, updated: 1, removed: 2 }
const typeCompare = typeOrder[a.type] - typeOrder[b.type]
if (typeCompare !== 0) return typeCompare
@@ -223,15 +202,15 @@ async function computeDependencyDiffs(
}
async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[] | null> {
if (!inst.link) return null
if (!modpackVersionId.value || !inst.link.version_id) return null
if (!inst.linked_data) return null
if (!modpackVersionId.value || !inst.linked_data.version_id) return null
try {
// 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.
// 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.
modpackVersion.value = await get_version(modpackVersionId.value, 'bypass')
const instanceModpackVersion = await get_version(inst.link.version_id, 'bypass')
const instanceModpackVersion = await get_version(inst.linked_data.version_id, 'bypass')
// Compute dependency diffs between current and latest version
if (instanceModpackVersion && modpackVersion.value) {
@@ -247,25 +226,42 @@ async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[
return null
}
watch(
() => instance.value,
async (newInstance) => {
if (!newInstance) return
const result = await checkUpdateAvailable(newInstance)
diffs.value = result || []
},
{ immediate: true, deep: true },
)
async function handleUpdate() {
const serverProjectId = instance.value?.link?.project_id
hide()
const serverProjectId = instance.value?.linked_data?.project_id
if (serverProjectId) startInstallingServer(serverProjectId)
try {
if (modpackVersionId.value && instance.value) {
const job = await update_managed_modrinth_version(instance.value.id, modpackVersionId.value)
await wait_for_install_job(job.job_id)
await update_managed_modrinth_version(instance.value.path, modpackVersionId.value)
await onUpdateComplete.value()
}
} catch (error) {
console.error('Error updating instance:', error)
} finally {
if (serverProjectId) stopInstallingServer(serverProjectId)
emit('complete')
}
}
function handleReport() {
if (instance.value?.linked_data?.project_id) {
openUrl(
`https://modrinth.com/report?item=project&itemID=${instance.value.linked_data.project_id}`,
)
}
}
function handleDecline() {
emit('cancel')
hide()
}
function show(
@@ -276,15 +272,8 @@ function show(
) {
instance.value = instanceVal
modpackVersionId.value = modpackVersionIdVal
modpackVersion.value = null
diffs.value = []
onUpdateComplete.value = callback
diffModal.value?.show(e)
void checkUpdateAvailable(instanceVal).then((result) => {
if (instance.value?.id === instanceVal.id && modpackVersionId.value === modpackVersionIdVal) {
diffs.value = result || []
}
})
}
function hide() {
@@ -296,25 +285,23 @@ const messages = defineMessages({
id: 'app.modal.update-to-play.header',
defaultMessage: 'Update to play',
},
updateRequired: {
id: 'app.modal.update-to-play.update-required',
defaultMessage: 'Update required',
},
updateRequiredDescription: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
'An update is required to play {name}. Please update to latest version to launch the game.',
},
removed: {
id: 'app.modal.update-to-play.removed',
defaultMessage: 'Removed',
},
externalWarningDescription: {
id: 'app.modal.update-to-play.server-modpack-unknown-files-description',
defaultMessage:
'This server modpack update contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust.',
'An update is required to play {name}. Please update to the latest version to launch the game.',
},
})
const hasUpdate = computed(() => {
if (!instance.value?.link) return false
return modpackVersionId.value != null && modpackVersionId.value !== instance.value.link.version_id
if (!instance.value?.linked_data) return false
return (
modpackVersionId.value != null &&
modpackVersionId.value !== instance.value.linked_data.version_id
)
})
defineExpose({ show, hide, hasUpdate })
@@ -10,10 +10,8 @@ import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
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 worldsInHomeFeatureFlag = 'worlds_in_home' as FeatureFlag
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
const messages = defineMessages({
colorThemeTitle: {
@@ -102,23 +100,6 @@ 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())
@@ -172,6 +153,16 @@ 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">
@@ -192,44 +183,6 @@ 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">
@@ -264,12 +217,12 @@ watch(
<p class="m-0 mt-1">{{ formatMessage(messages.jumpBackIntoWorldsDescription) }}</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag(worldsInHomeFlag)"
:model-value="themeStore.getFeatureFlag(worldsInHomeFeatureFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(worldsInHomeFlag)
themeStore.featureFlags[worldsInHomeFlag] = newValue
settings.feature_flags[worldsInHomeFlag] = newValue
const newValue = !themeStore.getFeatureFlag(worldsInHomeFeatureFlag)
themeStore.featureFlags[worldsInHomeFeatureFlag] = newValue
settings.feature_flags[worldsInHomeFeatureFlag] = newValue
}
"
/>
@@ -283,32 +236,13 @@ watch(
<p class="m-0 mt-1">{{ formatMessage(messages.unknownPackWarningDescription) }}</p>
</div>
<Toggle
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFlag)"
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)"
@update:model-value="
(e) => {
const warnBeforeUnknownPackInstall = !!e
const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
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
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = skipUnknownPackWarning
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = skipUnknownPackWarning
}
"
/>
@@ -1,44 +1,12 @@
<script setup lang="ts">
import { Settings2Icon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
injectPageContext,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { open_ads_consent_preferences } from '@/helpers/ads.js'
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'
import { get, set } from '@/helpers/settings.ts'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const { adConsentAvailable } = injectPageContext()
const settings = ref(await get())
const messages = defineMessages({
adsConsentTitle: {
id: 'app.ads-consent.title',
defaultMessage: 'Your privacy and how ads support Modrinth',
},
adsConsentIntro: {
id: 'app.settings.privacy.ads-consent.intro',
defaultMessage:
'Ads make Modrinth possible and fund creator payouts. Our partners may store or access cookies in the app to personalize ads and measure performance. You can opt out or manage your preferences below.',
},
adsConsentManage: {
id: 'app.ads-consent.manage',
defaultMessage: 'Manage preferences',
},
})
async function manageAdsPreferences() {
await open_ads_consent_preferences().catch(handleError)
}
watch(
settings,
async () => {
@@ -55,26 +23,18 @@ watch(
</script>
<template>
<div v-if="adConsentAvailable">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.adsConsentTitle) }}
</h2>
<div class="mt-1 flex flex-col gap-2.5 items-start">
<div class="flex flex-col gap-1 items-start">
<div class="text-sm">
{{ formatMessage(messages.adsConsentIntro) }}
</div>
</div>
<ButtonStyled>
<button class="!shadow-none" @click="manageAdsPreferences">
<Settings2Icon aria-hidden="true" />
{{ formatMessage(messages.adsConsentManage) }}
</button>
</ButtonStyled>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">Personalized ads</h2>
<p class="m-0 mt-1 text-sm">
Modrinth's ad provider, Aditude, shows ads based on your preferences. By disabling this
option, you opt out and ads will no longer be shown based on your interests.
</p>
</div>
<Toggle id="personalized-ads" v-model="settings.personalized_ads" />
</div>
<div class="mt-8 flex items-center justify-between gap-4">
<div class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">Telemetry</h2>
<p class="m-0 mt-1 text-sm">
@@ -1,5 +1,5 @@
<script setup>
import { BoxIcon, FolderOpenIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import { BoxIcon, 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,13 +7,9 @@ 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,
@@ -52,19 +48,6 @@ 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,
@@ -114,7 +97,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="handlePurgeCacheClick">
<button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()">
<TrashIcon />
Purge cache
</button>
@@ -153,16 +136,5 @@ 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,100 +0,0 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="warning" max-width="500px">
<p class="m-0 text-secondary">
<IntlFormatted :message-id="messages.body" :values="{ instanceName }">
<template #bold="{ children }">
<span class="font-medium text-contrast"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</p>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleGoToInstance">
{{ formatMessage(messages.instance) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button @click="handleInstallAnyway">
<DownloadIcon />
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { DownloadIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.instance.shared-instance-already-installed.header',
defaultMessage: 'Shared instance already installed',
},
body: {
id: 'app.instance.shared-instance-already-installed.body',
defaultMessage:
'This shared instance is already installed as <bold>{instanceName}</bold>. Are you sure you want to install another copy?',
},
instance: {
id: 'app.instance.shared-instance-already-installed.instance',
defaultMessage: 'Instance',
},
installAnyway: {
id: 'app.instance.shared-instance-already-installed.install-anyway',
defaultMessage: 'Install anyway',
},
})
const emit = defineEmits<{
(e: 'cancel' | 'go-to-instance' | 'install-anyway'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const instanceName = ref('')
function show(name: string) {
instanceName.value = name
modal.value?.show()
}
function handleCancel() {
modal.value?.hide()
emit('cancel')
}
function handleGoToInstance() {
modal.value?.hide()
emit('go-to-instance')
}
function handleInstallAnyway() {
modal.value?.hide()
emit('install-anyway')
}
defineExpose({
show,
})
</script>
@@ -1,195 +0,0 @@
<template>
<div v-if="canUnpublish" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{ formatMessage(messages.title) }}</span>
<div>
<ButtonStyled color="orange">
<button :disabled="busy" @click="unpublishModal?.show()">
<SpinnerIcon v-if="unpublishing" class="animate-spin" />
<UnlinkIcon v-else class="size-5" />
{{ formatMessage(unpublishing ? messages.unpublishingButton : messages.unpublishButton) }}
</button>
</ButtonStyled>
</div>
<span class="text-primary">{{ formatMessage(messages.unpublishDescription) }}</span>
</div>
<div v-if="canUnlink" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.linkedTitle)
}}</span>
<div>
<ButtonStyled color="orange">
<button :disabled="busy" @click="unlinkModal?.show()">
<SpinnerIcon v-if="unlinking" class="animate-spin" />
<UnlinkIcon v-else class="size-5" />
{{ formatMessage(unlinking ? messages.unlinkingButton : messages.unlinkButton) }}
</button>
</ButtonStyled>
</div>
<span class="text-primary">{{ formatMessage(messages.unlinkDescription) }}</span>
</div>
<NewModal
ref="unpublishModal"
:header="formatMessage(messages.unpublishModalHeader)"
fade="warning"
max-width="500px"
>
<Admonition type="warning" :header="formatMessage(messages.unpublishModalAdmonitionHeader)">{{
formatMessage(messages.unpublishModalBody)
}}</Admonition>
<template #actions
><div class="flex justify-end gap-2">
<ButtonStyled type="outlined"
><button class="!border" @click="unpublishModal?.hide()">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
><ButtonStyled color="orange"
><button :disabled="busy" @click="confirmUnpublish">
<UnlinkIcon />{{ formatMessage(messages.unpublishButton) }}
</button></ButtonStyled
>
</div></template
>
</NewModal>
<NewModal
ref="unlinkModal"
:header="formatMessage(messages.unlinkModalHeader)"
fade="warning"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition type="warning" :header="formatMessage(messages.unlinkModalAdmonitionHeader)">{{
formatMessage(messages.unlinkModalBody)
}}</Admonition>
<InlineBackupCreator
ref="backupCreator"
backup-name="Before unlinking shared instance"
@update:buttons-disabled="backupBusy = $event"
/>
</div>
<template #actions
><div class="flex justify-end gap-2">
<ButtonStyled type="outlined"
><button class="!border" @click="unlinkModal?.hide()">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
><ButtonStyled color="orange"
><button :disabled="busy || backupBusy" @click="confirmUnlink">
<UnlinkIcon />{{ formatMessage(messages.unlinkButton) }}
</button></ButtonStyled
>
</div></template
>
</NewModal>
</template>
<script setup lang="ts">
import { SpinnerIcon, UnlinkIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
InlineBackupCreator,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const props = withDefaults(
defineProps<{
canUnpublish?: boolean
canUnlink?: boolean
busy: boolean
unpublishing?: boolean
unlinking?: boolean
unpublish?: () => Promise<void>
unlink?: () => Promise<void>
}>(),
{
canUnpublish: false,
canUnlink: false,
unpublishing: false,
unlinking: false,
unpublish: undefined,
unlink: undefined,
},
)
const { formatMessage } = useVIntl()
const unpublishModal = ref<InstanceType<typeof NewModal>>()
const unlinkModal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const backupBusy = ref(false)
async function confirmUnpublish() {
unpublishModal.value?.hide()
await props.unpublish?.()
}
async function confirmUnlink() {
unlinkModal.value?.hide()
await props.unlink?.()
}
const messages = defineMessages({
title: {
id: 'installation-settings.shared-instance.title',
defaultMessage: 'Unpublish instance',
},
linkedTitle: {
id: 'installation-settings.shared-instance.linked-title',
defaultMessage: 'Linked shared instance',
},
unpublishButton: {
id: 'installation-settings.shared-instance.unpublish-button',
defaultMessage: 'Unpublish shared instance',
},
unpublishingButton: {
id: 'installation-settings.shared-instance.unpublishing-button',
defaultMessage: 'Unpublishing...',
},
unpublishDescription: {
id: 'installation-settings.shared-instance.unpublish-description',
defaultMessage:
'Remove this shared instance from Modrinth and stop sending updates to anyone using it. Your local instance will not be affected.',
},
unlinkButton: {
id: 'installation-settings.shared-instance.unlink-button',
defaultMessage: 'Unlink shared instance',
},
unlinkingButton: {
id: 'installation-settings.shared-instance.unlinking-button',
defaultMessage: 'Unlinking...',
},
unlinkDescription: {
id: 'installation-settings.shared-instance.unlink-description',
defaultMessage: 'Disconnect this local instance from future shared updates.',
},
unpublishModalHeader: {
id: 'installation-settings.unpublish-shared-instance.modal.header',
defaultMessage: 'Unpublish shared instance',
},
unpublishModalAdmonitionHeader: {
id: 'installation-settings.unpublish-shared-instance.modal.admonition-header',
defaultMessage: 'Unpublishing shared instance',
},
unpublishModalBody: {
id: 'installation-settings.unpublish-shared-instance.modal.admonition-body',
defaultMessage:
"This deletes the shared instance from Modrinth's servers. People using it in the Modrinth App will stop receiving updates, but your local instance and its content will stay on this device.",
},
unlinkModalHeader: {
id: 'installation-settings.unlink-shared-instance.modal.header',
defaultMessage: 'Unlink shared instance',
},
unlinkModalAdmonitionHeader: {
id: 'installation-settings.unlink-shared-instance.modal.admonition-header',
defaultMessage: 'Unlinking shared instance',
},
unlinkModalBody: {
id: 'installation-settings.unlink-shared-instance.modal.admonition-body',
defaultMessage:
'This only affects your local instance. Your installed content will stay on this device, and the shared instance and other people using it will not be affected.',
},
})
</script>
@@ -1,189 +0,0 @@
<template>
<ContentDiffModal
ref="publishReviewModal"
:header="formatMessage(messages.header)"
:admonition-header="formatMessage(messages.admonitionHeader)"
:description="formatMessage(messages.description)"
:diffs="publishDiffs"
:confirm-label="formatMessage(messages.publishButton)"
:confirm-icon="UploadIcon"
:confirm-disabled="publishDiffs.length === 0 && selectedConfigPaths.length === 0"
:added-label="formatMessage(messages.addedLabel)"
:removed-label="formatMessage(messages.removedLabel)"
@confirm="publishChanges"
@cancel="finishReview"
>
<template #additional-content>
<Accordion
class="min-w-0"
overflow-visible
button-class="w-full bg-transparent m-0 p-0 border-none"
>
<template #title>
<FileCogIcon class="size-4 shrink-0 text-primary" />
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.configTitle) }}
</span>
</template>
<div class="flex min-w-0 flex-col gap-3 pt-4">
<div class="max-h-[292px] overflow-y-auto rounded-[20px]">
<FileTreeSelect
v-model="selectedConfigPaths"
:items="configFileItems"
:show-size="false"
:show-modified="false"
/>
</div>
</div>
</Accordion>
</template>
</ContentDiffModal>
</template>
<script setup lang="ts">
import { FileCogIcon, UploadIcon } from '@modrinth/assets'
import {
Accordion,
type ContentDiffItem,
ContentDiffModal,
defineMessages,
FileTreeSelect,
type FileTreeSelectItem,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
getSharedInstanceUnavailableReason,
isSharedInstanceUnavailableError,
} from '@/helpers/install'
import { get_shared_instance_publish_preview, publish_shared_instance } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
type SharedInstancePublishState = 'idle' | 'reviewing' | 'publishing'
const props = defineProps<{
instance: GameInstance
}>()
const emit = defineEmits<{
published: []
'state-change': [state: SharedInstancePublishState]
}>()
const { formatMessage } = useVIntl()
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
const publishDiffs = ref<ContentDiffItem[]>([])
const configFilePaths = ref<string[]>([])
const selectedConfigPaths = ref<string[]>([])
const state = ref<SharedInstancePublishState>('idle')
const configFileItems = computed<FileTreeSelectItem[]>(() =>
configFilePaths.value.map((path) => ({ path, type: 'file' })),
)
async function show(e?: MouseEvent) {
if (state.value !== 'idle') return
setState('reviewing')
let reviewOpened = false
try {
const preview = await get_shared_instance_publish_preview(props.instance.id)
if (!preview) {
notifySharedInstanceUnavailable()
emit('published')
return
}
publishDiffs.value = preview.diffs.map((diff) => ({
type: diff.type,
projectName: diff.projectName ?? undefined,
fileName: diff.fileName ?? undefined,
currentVersionName: diff.currentVersionName ?? undefined,
newVersionName: diff.newVersionName ?? undefined,
fileCount: diff.configFileCount ?? undefined,
disabled: diff.disabled,
}))
configFilePaths.value = preview.configFiles
selectedConfigPaths.value = []
if (!publishReviewModal.value) return
publishReviewModal.value.show(e)
reviewOpened = true
} catch (error) {
handlePublishError(error)
} finally {
if (!reviewOpened) finishReview()
}
}
async function publishChanges() {
if (state.value !== 'reviewing') return
setState('publishing')
try {
await publish_shared_instance(props.instance.id, selectedConfigPaths.value)
emit('published')
} catch (error) {
handlePublishError(error)
} finally {
setState('idle')
}
}
function finishReview() {
if (state.value === 'reviewing') {
setState('idle')
}
}
function handlePublishError(error: unknown) {
if (isSharedInstanceUnavailableError(error)) {
notifySharedInstanceUnavailable(getSharedInstanceUnavailableReason(error))
emit('published')
return
}
notifySharedInstanceError(error)
}
function setState(nextState: SharedInstancePublishState) {
state.value = nextState
emit('state-change', nextState)
}
const messages = defineMessages({
header: {
id: 'instance.shared-instance.publish-review.header',
defaultMessage: 'Review changes',
},
admonitionHeader: {
id: 'instance.shared-instance.publish-review.admonition-header',
defaultMessage: 'Push update to players',
},
description: {
id: 'instance.shared-instance.publish-review.description',
defaultMessage:
'Review the content changes and choose any config files to include in this update.',
},
publishButton: {
id: 'instance.shared-instance.publish-review.publish-button',
defaultMessage: 'Push update',
},
addedLabel: {
id: 'instance.shared-instance.publish-review.added-label',
defaultMessage: 'Added',
},
removedLabel: {
id: 'instance.shared-instance.publish-review.removed-label',
defaultMessage: 'Removed',
},
configTitle: {
id: 'instance.shared-instance.publish-review.config-title-v2',
defaultMessage: 'Select config files',
},
})
defineExpose({ show })
</script>
@@ -1,134 +0,0 @@
<template>
<ContentDiffModal
ref="modal"
:header="formatMessage(messages.updateToPlay)"
:admonition-header="formatMessage(messages.updateRequired)"
:description="instance ? formatMessage(messages.description, { name: instance.name }) : ''"
:diffs="diffs"
:confirm-label="formatMessage(commonMessages.updateButton)"
:confirm-icon="DownloadIcon"
:added-label="formatMessage(messages.addedLabel)"
:removed-label="formatMessage(messages.removedLabel)"
show-report-button
show-external-warnings
:external-warning-description="formatMessage(messages.externalWarningDescription)"
@confirm="update"
@cancel="emit('cancel')"
@report="emit('report', $event)"
/>
</template>
<script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets'
import {
commonMessages,
type ContentDiffItem,
ContentDiffModal,
defineMessages,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
getSharedInstanceUnavailableReason,
install_update_shared_instance,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
type SharedInstanceUpdatePreview,
wait_for_install_job,
} from '@/helpers/install'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
type UpdateCompleteCallback = () => void | Promise<void>
const emit = defineEmits<{
cancel: []
complete: []
report: [event?: MouseEvent]
sharedInstanceUnavailable: [reason: SharedInstanceUnavailableReason | null]
}>()
const modal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const preview = ref<SharedInstanceUpdatePreview | null>(null)
const onComplete = ref<UpdateCompleteCallback>(() => {})
const { formatMessage } = useVIntl()
const { notifySharedInstanceError } = useSharedInstanceErrors()
const diffs = computed<ContentDiffItem[]>(
() =>
preview.value?.diffs
// TODO: This is TEMP!!! Hashing needs to be done on backend
.filter((diff) => !(diff.type === 'updated' && !diff.projectId && diff.fileName))
.map((diff) => ({
type: diff.type,
projectName: diff.projectName ?? undefined,
fileName: diff.fileName ?? undefined,
currentVersionName: diff.currentVersionName ?? undefined,
newVersionName: diff.newVersionName ?? undefined,
fileCount: diff.configFileCount ?? undefined,
disabled: diff.disabled,
external: diff.type === 'added' && !diff.projectId && !!diff.fileName,
})) ?? [],
)
async function update() {
try {
if (instance.value) {
const job = await install_update_shared_instance(instance.value.id)
await wait_for_install_job(job.job_id)
await onComplete.value()
}
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
emit('sharedInstanceUnavailable', getSharedInstanceUnavailableReason(error))
return
}
notifySharedInstanceError(error)
} finally {
emit('complete')
}
}
function show(
instanceValue: GameInstance,
previewValue: SharedInstanceUpdatePreview,
callback: UpdateCompleteCallback = () => {},
event?: MouseEvent,
) {
instance.value = instanceValue
preview.value = previewValue
onComplete.value = callback
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
}
const messages = defineMessages({
updateToPlay: { id: 'app.modal.update-to-play.header', defaultMessage: 'Update to play' },
updateRequired: {
id: 'app.modal.update-to-play.update-required',
defaultMessage: 'Update required',
},
description: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
'An update is required to play {name}. Please update to latest version to launch the game.',
},
addedLabel: {
id: 'app.modal.update-to-play.shared-instance-added-label',
defaultMessage: 'Added',
},
removedLabel: {
id: 'app.modal.update-to-play.shared-instance-removed-label',
defaultMessage: 'Removed',
},
externalWarningDescription: {
id: 'app.modal.update-to-play.shared-instance-unknown-files-description',
defaultMessage:
'This shared instance update contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust.',
},
})
defineExpose({ show, hide })
</script>
@@ -1,533 +0,0 @@
<template>
<NewModal
ref="modal"
:header="
reportMode
? formatMessage(messages.reportSharedInstance)
: formatMessage(messages.installToPlay)
"
:closable="!submitLoading"
:on-hide="handleHide"
:max-width="reportMode ? '816px' : '544px'"
:width="reportMode ? '816px' : '544px'"
:no-padding="reportMode"
:scrollable="reportMode"
>
<div v-if="preview" class="flex w-full flex-col gap-6" :class="{ 'p-6 pb-2': reportMode }">
<Admonition v-if="reportMode" type="info">
<div class="flex flex-col gap-2">
<p class="m-0">
<IntlFormatted :message-id="messages.reportDescription">
<template #rules-link="{ children }">
<AutoLink class="text-link hover:underline" to="https://modrinth.com/legal/rules">
<component :is="() => children" />
</AutoLink>
</template>
<template #terms-link="{ children }">
<AutoLink class="text-link hover:underline" to="https://modrinth.com/legal/terms">
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</p>
<ul class="m-0 list-disc pl-5">
<li>
<IntlFormatted :message-id="messages.supportAndBugReports">
<template #support-link="{ children }">
<AutoLink class="text-link hover:underline" to="https://support.modrinth.com">
<component :is="() => children" />
</AutoLink>
</template>
<template #github-link="{ children }">
<AutoLink
class="text-link hover:underline"
to="https://github.com/modrinth/code/issues"
>
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</li>
<li>
<IntlFormatted :message-id="messages.legalClaims">
<template #copyright-link="{ children }">
<AutoLink
class="text-link hover:underline"
to="https://modrinth.com/legal/copyright"
>
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</li>
</ul>
</div>
</Admonition>
<p v-else class="m-0 text-primary">
{{ formatMessage(messages.inviteWarning) }}
</p>
<SharedInstanceInstallSummary
:preview="preview"
:heading="reportMode ? formatMessage(messages.contentYouAreReporting) : undefined"
@view-contents="openViewContents"
/>
<Transition
enter-active-class="overflow-hidden transition-all duration-200 ease-out"
enter-from-class="max-h-0 opacity-0"
enter-to-class="max-h-[400px] opacity-100"
leave-active-class="overflow-hidden transition-all duration-200 ease-in"
leave-from-class="max-h-[400px] opacity-100"
leave-to-class="max-h-0 opacity-0"
>
<div v-if="reportMode" class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.reportReason) }}
</span>
<Combobox v-model="reportReason" :options="reportReasonOptions" />
</div>
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.additionalContext) }}
</span>
<MarkdownEditor
v-model="additionalContext"
:placeholder="formatMessage(messages.additionalContextPlaceholder)"
:on-image-upload="onImageUpload"
:min-height="120"
:max-height="240"
/>
</div>
<div v-if="reportOnly" class="flex flex-col gap-2">
<Checkbox v-model="deleteInstance" :label="formatMessage(messages.deleteInstance)" />
</div>
</div>
</Transition>
<Admonition
v-if="!reportMode && hasExternalFiles"
type="warning"
:header="formatMessage(messages.unknownFilesWarning)"
>
{{ formatMessage(messages.unknownFilesDescription) }}
</Admonition>
<div v-if="!reportMode && hasExternalFiles" class="relative w-full">
<div
ref="externalFileTable"
class="max-h-[242px] overflow-y-auto rounded-2xl"
@scroll="checkTableScrollState"
>
<Table
:columns="externalFileColumns"
:data="externalFileRows"
row-key="id"
virtualized
:virtual-row-height="48"
class="shadow-sm"
>
<template #cell-name="{ value }">
<span class="block truncate" :title="String(value)">{{ value }}</span>
</template>
</Table>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTableTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTableBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
<p v-if="!reportMode && hasExternalFiles" class="m-0 text-primary">
{{ formatMessage(messages.reviewedFiles) }}
</p>
<div v-if="!reportMode" class="flex w-full items-center justify-between gap-2">
<ButtonStyled color="red" type="transparent">
<button @click="reportMode = true">
<ReportIcon />{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
<div class="flex items-center gap-2">
<template v-if="hasExternalFiles">
<ButtonStyled type="transparent" color="orange">
<button @click="accept">
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleCancel">
<BanIcon />{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled type="outlined">
<button class="!border" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="accept">
<DownloadIcon />{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
</template>
</div>
</div>
</div>
<template v-if="reportMode" #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border" :disabled="submitLoading" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canSubmitReport" @click="submitReport">
<SpinnerIcon v-if="submitLoading" class="animate-spin" />
<SendIcon v-else />
{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<ModpackContentModal
ref="contentModal"
:header="formatMessage(messages.sharedInstanceContent)"
:modpack-name="preview?.name ?? ''"
:modpack-icon-url="preview?.iconUrl ?? undefined"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { BanIcon, DownloadIcon, ReportIcon, SendIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
AutoLink,
ButtonStyled,
Checkbox,
Combobox,
type ComboboxOption,
commonMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
MarkdownEditor,
ModpackContentModal,
NewModal,
Table,
type TableColumn,
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { toError } from '@/helpers/errors'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
import { create_report } from '@/helpers/reports'
import SharedInstanceInstallSummary from './shared-instance-install-summary.vue'
import { useSharedInstancePreviewContent } from './use-shared-instance-preview-content'
type ExternalFileColumn = 'name'
type ExternalFileRow = {
id: string
name: string
}
const modal = ref<InstanceType<typeof NewModal>>()
const contentModal = ref<InstanceType<typeof ModpackContentModal>>()
const externalFileTable = ref<HTMLElement | null>(null)
const preview = ref<SharedInstanceInstallPreview | null>(null)
const install = ref<() => void | Promise<void>>(() => {})
const reportMode = ref(false)
const reportOnly = ref(false)
type ReportReason = 'malicious' | 'inappropriate' | 'spam'
const reportReason = ref<ReportReason>('malicious')
const additionalContext = ref('')
const deleteInstance = ref(true)
const submitLoading = ref(false)
const uploadedImageIDs = ref<string[]>([])
const emit = defineEmits<{
reported: [deleteInstance: boolean]
}>()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { addNotification, handleError } = injectNotificationManager()
const { load } = useSharedInstancePreviewContent()
const {
showTopFade: showTableTopFade,
showBottomFade: showTableBottomFade,
checkScrollState: checkTableScrollState,
forceCheck: forceCheckTableScroll,
} = useScrollIndicator(externalFileTable)
const hasExternalFiles = computed(() => Boolean(preview.value?.externalFiles.length))
const externalFileRows = computed<ExternalFileRow[]>(() =>
(preview.value?.externalFiles ?? [])
.map((file, index) => ({
id: `${index}-${file.fileType}-${file.fileName}`,
name: file.fileName,
}))
.sort((left, right) => left.name.localeCompare(right.name)),
)
const reportReasonOptions = computed<ComboboxOption<ReportReason>[]>(() => [
{ value: 'malicious', label: formatMessage(messages.maliciousReason) },
{ value: 'inappropriate', label: formatMessage(messages.inappropriateReason) },
{ value: 'spam', label: formatMessage(messages.spamReason) },
])
const canSubmitReport = computed(
() => Boolean(preview.value && additionalContext.value.trim()) && !submitLoading.value,
)
async function accept() {
hide()
try {
await install.value()
} catch (error) {
console.error('Failed to install shared instance:', error)
}
}
async function openViewContents() {
if (!preview.value) return
contentModal.value?.showLoading()
try {
contentModal.value?.show(await load(preview.value))
} catch (error) {
console.error('Failed to load shared instance contents:', error)
contentModal.value?.show([])
}
}
async function submitReport() {
const reportPreview = preview.value
const body = additionalContext.value.trim()
if (!reportPreview || !body || submitLoading.value) return
submitLoading.value = true
try {
const uploadedImages = uploadedImageIDs.value.slice(-10)
await create_report({
report_type: reportReason.value,
item_type: 'shared-instance',
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
body,
uploaded_images: uploadedImages,
})
const shouldDeleteInstance = reportOnly.value && deleteInstance.value
hide()
addNotification({
type: 'success',
title: formatMessage(messages.reportSubmitted),
})
emit('reported', shouldDeleteInstance)
} catch (error) {
handleError(toError(error))
} finally {
submitLoading.value = false
}
}
async function onImageUpload(file: File) {
const imageExtensionByType: Partial<Record<string, Labrinth.Images.v3.ImageExtension>> = {
'image/gif': 'gif',
'image/jpeg': 'jpeg',
'image/png': 'png',
'image/webp': 'webp',
}
const extension = imageExtensionByType[file.type]
if (!extension) {
throw new Error(formatMessage(messages.invalidImageType))
}
if (file.size > 1024 * 1024) {
throw new Error(formatMessage(messages.imageTooLarge))
}
const image = await client.labrinth.images_v3.uploadImage(file, extension, {
context: 'report',
}).promise
uploadedImageIDs.value.push(image.id)
return image.url
}
function handleCancel() {
if (reportMode.value && !reportOnly.value) {
reportMode.value = false
void nextTick(() => forceCheckTableScroll())
return
}
hide()
}
function handleHide() {
resetReportState()
show_ads_window()
}
function resetReportState() {
reportMode.value = false
reportOnly.value = false
reportReason.value = 'malicious'
additionalContext.value = ''
deleteInstance.value = true
submitLoading.value = false
uploadedImageIDs.value = []
}
function show(
previewValue: SharedInstanceInstallPreview,
installValue: () => void | Promise<void>,
event?: MouseEvent,
) {
resetReportState()
install.value = installValue
showPreview(previewValue, event)
}
function showReport(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
resetReportState()
reportMode.value = true
reportOnly.value = true
install.value = () => {}
showPreview(previewValue, event)
}
function showPreview(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
preview.value = previewValue
hide_ads_window()
modal.value?.show(event)
void nextTick(() => forceCheckTableScroll())
}
function hide() {
modal.value?.hide()
}
const messages = defineMessages({
installToPlay: { id: 'app.modal.install-to-play.header', defaultMessage: 'Install to play' },
reportSharedInstance: {
id: 'app.modal.install-to-play.report-shared-instance-header',
defaultMessage: 'Report shared instance',
},
reportSubmitted: {
id: 'app.modal.install-to-play.report-submitted',
defaultMessage: 'Report submitted',
},
sharedInstanceContent: {
id: 'app.modal.install-to-play.shared-instance-content',
defaultMessage: 'Shared instance content',
},
inviteWarning: {
id: 'app.modal.install-to-play.invite-warning',
defaultMessage:
'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
},
reportDescription: {
id: 'app.modal.install-to-play.report-description',
defaultMessage:
'Use this form to report instances that may violate our <rules-link>Rules</rules-link> or <terms-link>Terms of Use</terms-link>.',
},
supportAndBugReports: {
id: 'app.modal.install-to-play.report-support-and-bugs',
defaultMessage:
'For support requests, contact our <support-link>support team</support-link>. For bug reports, open a <github-link>GitHub issue</github-link>.',
},
legalClaims: {
id: 'app.modal.install-to-play.report-legal-claims',
defaultMessage:
'For DMCA notices or other legal claims, see our <copyright-link>Copyright Policy</copyright-link>.',
},
contentYouAreReporting: {
id: 'app.modal.install-to-play.content-you-are-reporting',
defaultMessage: 'Instance youre reporting',
},
reportReason: {
id: 'app.modal.install-to-play.report-reason',
defaultMessage: 'Which rule does this instance violate?',
},
maliciousReason: {
id: 'app.modal.install-to-play.report-reason.malicious',
defaultMessage: 'Malicious',
},
inappropriateReason: {
id: 'app.modal.install-to-play.report-reason.inappropriate',
defaultMessage: 'Inappropriate',
},
spamReason: {
id: 'app.modal.install-to-play.report-reason.spam',
defaultMessage: 'Spam',
},
additionalContext: {
id: 'app.modal.install-to-play.additional-context',
defaultMessage: 'Additional context',
},
additionalContextPlaceholder: {
id: 'app.modal.install-to-play.additional-context-placeholder',
defaultMessage: 'Include links and images if possible and relevant',
},
invalidImageType: {
id: 'app.modal.install-to-play.report-image-invalid-type',
defaultMessage: 'File is not an accepted image type',
},
imageTooLarge: {
id: 'app.modal.install-to-play.report-image-too-large',
defaultMessage: 'File exceeds the 1 MiB size limit',
},
deleteInstance: {
id: 'app.modal.install-to-play.delete-instance',
defaultMessage: 'Delete instance',
},
unknownFilesWarning: {
id: 'app.modal.install-to-play.unknown-files-warning',
defaultMessage: 'Unknown files warning',
},
unknownFilesDescription: {
id: 'app.modal.install-to-play.shared-instance-unknown-files-description',
defaultMessage:
'This shared instance contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust.',
},
unrecognizedFiles: {
id: 'app.modal.install-to-play.unrecognized-files',
defaultMessage: 'Unrecognized files',
},
reviewedFiles: {
id: 'app.modal.install-to-play.reviewed-files',
defaultMessage:
'A file is only reviewed if its published to Modrinth, regardless of its file format (including .mrpack).',
},
installAnyway: {
id: 'app.modal.install-to-play.install-anyway',
defaultMessage: 'Install anyway',
},
dontInstall: {
id: 'app.modal.install-to-play.external-files-dont-install',
defaultMessage: "Don't install",
},
installButton: { id: 'app.modal.install-to-play.install-button', defaultMessage: 'Install' },
})
const externalFileColumns = computed<TableColumn<ExternalFileColumn>[]>(() => [
{
key: 'name',
label: formatMessage(messages.unrecognizedFiles),
cellClass: '!h-12',
},
])
defineExpose({ show, showReport, hide })
</script>
@@ -1,60 +0,0 @@
<template>
<div class="flex flex-col gap-2.5">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">{{
heading ?? formatMessage(messages.sharedInstance)
}}</span>
<ButtonStyled type="transparent">
<button @click="emit('viewContents')">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3">
<Avatar
:src="preview.iconUrl"
:alt="preview.name"
size="56px"
no-shadow
class="!rounded-2xl"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="truncate font-semibold text-contrast">{{ preview.name }}</span>
<span class="truncate text-sm font-medium text-secondary">
{{ loaderDisplay }} {{ preview.gameVersion }}
<template v-if="preview.modCount">
· {{ formatMessage(messages.modCount, { count: preview.modCount }) }}
</template>
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { EyeIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, defineMessages, formatLoader, useVIntl } from '@modrinth/ui'
import { computed, toRefs } from 'vue'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
const props = defineProps<{ preview: SharedInstanceInstallPreview; heading?: string }>()
const { preview, heading } = toRefs(props)
const emit = defineEmits<{ viewContents: [] }>()
const { formatMessage } = useVIntl()
const loaderDisplay = computed(() =>
preview.value.loader ? formatLoader(formatMessage, preview.value.loader) : '',
)
const messages = defineMessages({
sharedInstance: {
id: 'app.modal.install-to-play.shared-instance',
defaultMessage: 'Shared instance',
},
viewContents: { id: 'app.modal.install-to-play.view-contents', defaultMessage: 'View contents' },
modCount: {
id: 'app.modal.install-to-play.mod-count',
defaultMessage: '{count, plural, one {# mod} other {# mods}}',
},
})
</script>
@@ -1,135 +0,0 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ContentItem } from '@modrinth/ui'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
type VersionDependency = Labrinth.Versions.v2.Dependency & { version_id?: string }
export function useSharedInstancePreviewContent() {
async function load(preview: SharedInstanceInstallPreview): Promise<ContentItem[]> {
return [
...preview.externalFiles.map(externalFileContentItem),
...(await modpackContentItems(preview)),
...(await contentItemsFromVersionIds(
preview.contentVersionIds.filter((id) => id !== preview.modpackVersionId),
)),
]
}
async function modpackContentItems(preview: SharedInstanceInstallPreview) {
if (!preview.modpackVersionId) return []
const version = await get_version(preview.modpackVersionId, 'must_revalidate')
return await contentItemsFromDependencies(version?.dependencies ?? [])
}
async function contentItemsFromDependencies(dependencies: Labrinth.Versions.v2.Dependency[]) {
const deps = dependencies as VersionDependency[]
const projectIds = unique(deps.map((dep) => dep.project_id).filter((id): id is string => !!id))
const versionIds = unique(deps.map((dep) => dep.version_id).filter((id): id is string => !!id))
const [projects, versions]: [Labrinth.Projects.v2.Project[], Labrinth.Versions.v2.Version[]] =
await Promise.all([
projectIds.length ? get_project_many(projectIds, 'must_revalidate') : [],
versionIds.length ? get_version_many(versionIds, 'must_revalidate') : [],
])
const projectMap = new Map(projects.map((project) => [project.id, project]))
const versionMap = new Map(versions.map((version) => [version.id, version]))
return deps.map((dependency): ContentItem => {
const project = dependency.project_id ? projectMap.get(dependency.project_id) : null
const version = dependency.version_id ? versionMap.get(dependency.version_id) : null
const fileName =
version?.files?.[0]?.filename ?? dependency.file_name ?? project?.title ?? 'Unknown'
return contentItem(
version?.id ?? project?.id ?? fileName,
fileName,
project,
version,
!project && !version,
dependency.project_id ?? fileName,
dependency.file_name ?? fileName,
)
})
}
async function contentItemsFromVersionIds(versionIds: string[]) {
const versions: Labrinth.Versions.v2.Version[] = versionIds.length
? await get_version_many(unique(versionIds), 'must_revalidate')
: []
const projectIds = unique(versions.map((version) => version.project_id).filter(Boolean))
const projects: Labrinth.Projects.v2.Project[] = projectIds.length
? await get_project_many(projectIds, 'must_revalidate')
: []
const projectMap = new Map(projects.map((project) => [project.id, project]))
return versions.map((version): ContentItem => {
const project = projectMap.get(version.project_id)
const fileName = version.files?.[0]?.filename ?? project?.title ?? version.name ?? 'Unknown'
return contentItem(
version.id,
fileName,
project,
version,
false,
version.project_id,
version.name,
)
})
}
return { load }
}
function contentItem(
id: string,
fileName: string,
project?: Labrinth.Projects.v2.Project | null,
version?: Labrinth.Versions.v2.Version | null,
external = false,
fallbackProjectId = id,
fallbackTitle = fileName,
projectType = project?.project_type ?? 'mod',
): ContentItem {
return {
id,
file_name: fileName,
project_type: projectType,
has_update: false,
update_version_id: null,
external,
project: {
id: project?.id ?? fallbackProjectId,
slug: project?.slug ?? fallbackProjectId,
title: project?.title ?? fallbackTitle,
icon_url: project?.icon_url ?? undefined,
},
...(version
? {
version: {
id: version.id,
file_name: fileName,
version_number: version.version_number ?? undefined,
date_published: version.date_published ?? undefined,
},
}
: {}),
}
}
function externalFileContentItem(
file: SharedInstanceInstallPreview['externalFiles'][number],
): ContentItem {
return contentItem(
`external:${file.fileType}:${file.fileName}`,
file.fileName,
null,
null,
true,
file.fileName,
file.fileName,
file.fileType,
)
}
function unique<T>(values: T[]) {
return Array.from(new Set(values))
}
@@ -1,48 +0,0 @@
<template>
<ModrinthAccountRequiredModal ref="accountRequiredModal" :request-auth="requestAuth" />
<SharedInstanceInstallModal ref="installModal" />
<SharedInstanceAlreadyInstalledModal
ref="alreadyInstalledModal"
@cancel="handleAlreadyInstalledCancel"
@go-to-instance="handleAlreadyInstalledGoToInstance"
@install-anyway="handleAlreadyInstalledInstallAnyway"
/>
</template>
<script setup lang="ts">
import { injectAuth } from '@modrinth/ui'
import { nextTick, ref } from 'vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue'
import SharedInstanceAlreadyInstalledModal from '@/components/ui/shared-instances/SharedInstanceAlreadyInstalledModal.vue'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth'
import type { SharedInstanceInviteHandler } from './shared-instance-invite-types'
import { useSharedInstanceInviteHandler } from './use-shared-instance-invite-handler'
const auth = injectAuth()
const installModal = ref<InstanceType<typeof SharedInstanceInstallModal>>()
const alreadyInstalledModal = ref<InstanceType<typeof SharedInstanceAlreadyInstalledModal>>()
const accountRequiredModal = ref<InstanceType<typeof ModrinthAccountRequiredModal>>()
const {
handleNotification,
installFromInviteId,
clearNotifications,
handleAlreadyInstalledCancel,
handleAlreadyInstalledGoToInstance,
handleAlreadyInstalledInstallAnyway,
} = useSharedInstanceInviteHandler(installModal, alreadyInstalledModal, accountRequiredModal)
async function requestAuth(flow: ModrinthAuthFlow) {
await auth.requestSignIn('', flow, { showModal: false })
await nextTick()
return !!auth.session_token.value
}
defineExpose<SharedInstanceInviteHandler>({
handleNotification,
installFromInviteId,
clearNotifications,
})
</script>
@@ -1,29 +0,0 @@
import type {
AppNotification,
SharedInstanceInvite,
SharedInstanceInviteNotificationBody,
} from './shared-instance-invite-types'
function optionalString(value: unknown) {
return typeof value === 'string' ? value : null
}
export function parseSharedInstanceInviteNotification(
notification: AppNotification,
): SharedInstanceInvite | null {
if (notification.body?.type !== 'shared_instance_invite') return null
const body = notification.body as SharedInstanceInviteNotificationBody
const sharedInstanceId = optionalString(body.shared_instance_id)
const sharedInstanceName = optionalString(body.shared_instance_name)
if (!sharedInstanceId || !sharedInstanceName) return null
return {
sharedInstanceId,
sharedInstanceName,
invitedById: optionalString(body.invited_by),
invitedByUsername: optionalString(body.invited_by_username),
invitedByAvatarUrl: optionalString(body.invited_by_avatar_url),
instanceIconUrl: optionalString(body.shared_instance_icon),
}
}
@@ -1,30 +0,0 @@
export type SharedInstanceInviteNotificationBody = {
type: 'shared_instance_invite'
shared_instance_id?: unknown
shared_instance_name?: unknown
invited_by?: unknown
invited_by_username?: unknown
invited_by_avatar_url?: unknown
shared_instance_icon?: unknown
}
export type AppNotification = {
id: string | number
read?: boolean
body?: { type?: unknown } & Record<string, unknown>
}
export type SharedInstanceInvite = {
sharedInstanceId: string
sharedInstanceName: string
invitedById: string | null
invitedByUsername: string | null
invitedByAvatarUrl: string | null
instanceIconUrl: string | null
}
export type SharedInstanceInviteHandler = {
handleNotification(notification: AppNotification): Promise<boolean>
installFromInviteId(inviteId: string): Promise<void>
clearNotifications(): void
}
@@ -1,282 +0,0 @@
import { ModrinthApiError } from '@modrinth/api-client'
import {
injectAuth,
injectModrinthClient,
injectNotificationManager,
injectPopupNotificationManager,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type Ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { config } from '@/config'
import { get_user } from '@/helpers/cache'
import { toError } from '@/helpers/errors'
import {
install_accept_shared_instance_invite,
install_get_shared_instance_preview,
install_shared_instance,
} from '@/helpers/install'
import { list } from '@/helpers/instance'
import { useTheming } from '@/store/state'
import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser'
import type { AppNotification, SharedInstanceInvite } from './shared-instance-invite-types'
type InstallModal = {
show(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
): void
}
type AccountRequiredModal = {
show(event?: MouseEvent): Promise<boolean>
}
type AlreadyInstalledModal = {
show(instanceName: string): void
}
export function useSharedInstanceInviteHandler(
installModal: Ref<InstallModal | undefined>,
alreadyInstalledModal: Ref<AlreadyInstalledModal | undefined>,
accountRequiredModal: Ref<AccountRequiredModal | undefined>,
) {
const auth = injectAuth()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const popupNotificationManager = injectPopupNotificationManager()
const queryClient = useQueryClient()
const router = useRouter()
const themeStore = useTheming()
const displayedNotifications = new Set<string | number>()
const displayedNotificationKeys = new Set<string>()
const popupNotificationIds = new Set<string | number>()
let notificationGeneration = 0
let pendingAlreadyInstalled:
| {
instanceId: string
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>
install: () => Promise<void>
onGoToInstance?: () => void | Promise<void>
}
| undefined
async function markNotificationRead(notification: AppNotification) {
try {
await client.labrinth.notifications_v2.markAsRead(String(notification.id))
} catch (error) {
if (error instanceof ModrinthApiError && error.statusCode === 404) return
throw error
}
}
async function resolveInvite(invite: SharedInstanceInvite) {
const [invitedBy, sharedInstance] = await Promise.all([
!invite.invitedByUsername && invite.invitedById
? get_user(invite.invitedById, 'bypass').catch(() => null)
: null,
client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => null),
])
return {
...invite,
invitedByUsername: invite.invitedByUsername ?? invitedBy?.username ?? null,
invitedByAvatarUrl: invite.invitedByAvatarUrl ?? invitedBy?.avatar_url ?? null,
instanceIconUrl: sharedInstance ? sharedInstance.icon : invite.instanceIconUrl,
}
}
function showInstall(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
) {
if (!installModal.value) throw new Error('Shared instance install modal is not available.')
installModal.value.show(preview, install)
}
async function showInstallOrAlreadyInstalled(
sharedInstanceId: string,
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
onGoToInstance?: () => void | Promise<void>,
) {
const existingInstance = (await list()).find(
(instance) => instance.shared_instance?.id === sharedInstanceId,
)
if (!existingInstance || themeStore.getFeatureFlag('skip_non_essential_warnings')) {
showInstall(preview, install)
return
}
if (!alreadyInstalledModal.value) {
throw new Error('Shared instance already installed modal is not available.')
}
pendingAlreadyInstalled = {
instanceId: existingInstance.id,
preview,
install,
onGoToInstance,
}
alreadyInstalledModal.value.show(existingInstance.name)
}
function handleAlreadyInstalledCancel() {
pendingAlreadyInstalled = undefined
}
async function handleAlreadyInstalledGoToInstance() {
const pending = pendingAlreadyInstalled
pendingAlreadyInstalled = undefined
if (!pending) return
if (pending.onGoToInstance) {
try {
await pending.onGoToInstance()
} catch (error) {
handleError(toError(error))
}
}
await router.push(`/instance/${encodeURIComponent(pending.instanceId)}/`)
}
function handleAlreadyInstalledInstallAnyway() {
const pending = pendingAlreadyInstalled
pendingAlreadyInstalled = undefined
if (!pending) return
showInstall(pending.preview, pending.install)
}
async function acceptNotification(notification: AppNotification, invite: SharedInstanceInvite) {
try {
const preview = await install_get_shared_instance_preview(
invite.sharedInstanceId,
invite.sharedInstanceName,
)
if (invite.instanceIconUrl) preview.iconUrl = invite.instanceIconUrl
await showInstallOrAlreadyInstalled(
invite.sharedInstanceId,
preview,
async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.sharedInstanceName,
invite.invitedById,
null,
null,
invite.instanceIconUrl,
)
await markNotificationRead(notification)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
},
() => markNotificationRead(notification),
)
} catch (error) {
handleError(toError(error))
}
}
async function handleNotification(notification: AppNotification) {
const parsedInvite = parseSharedInstanceInviteNotification(notification)
if (!parsedInvite) return false
if (displayedNotifications.has(notification.id)) return true
const generation = notificationGeneration
displayedNotifications.add(notification.id)
const invite = await resolveInvite(parsedInvite)
if (generation !== notificationGeneration) return true
const notificationKey = JSON.stringify([
invite.invitedById ?? invite.invitedByUsername,
invite.sharedInstanceName,
invite.instanceIconUrl,
])
if (displayedNotificationKeys.has(notificationKey)) {
await markNotificationRead(notification).catch((error) => handleError(toError(error)))
return true
}
displayedNotificationKeys.add(notificationKey)
const popupNotification = popupNotificationManager.addPopupNotification({
title: invite.sharedInstanceName,
autoCloseMs: null,
toast: {
type: 'instance-invite',
actorName: invite.invitedByUsername,
actorAvatarUrl: invite.invitedByAvatarUrl ?? undefined,
entityName: invite.sharedInstanceName,
entityIconUrl: invite.instanceIconUrl ?? undefined,
onAccept: () => acceptNotification(notification, invite),
onDecline: () =>
markNotificationRead(notification).catch((error) => handleError(toError(error))),
onOpenActor: () => {
if (invite.invitedByUsername) {
openUrl(`${config.siteUrl}/user/${encodeURIComponent(invite.invitedByUsername)}`)
}
},
},
})
popupNotificationIds.add(popupNotification.id)
return true
}
function clearNotifications() {
notificationGeneration++
for (const id of popupNotificationIds) {
popupNotificationManager.removeNotification(id)
}
displayedNotifications.clear()
displayedNotificationKeys.clear()
popupNotificationIds.clear()
pendingAlreadyInstalled = undefined
}
async function requireAccount() {
if (!auth.isReady?.value) {
await new Promise<void>((resolve) => {
const stop = watch(auth.isReady!, (ready) => {
if (ready) {
stop()
resolve()
}
})
})
}
if (auth.session_token.value) return true
return (await accountRequiredModal.value?.show()) ?? false
}
async function installFromInviteId(inviteId: string) {
try {
if (!(await requireAccount())) return
const invite = await install_accept_shared_instance_invite(inviteId)
await showInstallOrAlreadyInstalled(invite.sharedInstanceId, invite.preview, async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.preview.name,
invite.managerId,
invite.serverManagerName,
invite.serverManagerIconUrl,
invite.instanceIconUrl,
)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
})
} catch (error) {
handleError(toError(error))
}
}
return {
handleNotification,
installFromInviteId,
clearNotifications,
handleAlreadyInstalledCancel,
handleAlreadyInstalledGoToInstance,
handleAlreadyInstalledInstallAnyway,
}
}
@@ -1,242 +1,149 @@
<template>
<NewModal ref="modal" :on-hide="handleModalHide">
<UploadSkinModal ref="uploadModal" />
<ModalWrapper ref="modal" @on-hide="resetState">
<template #title>
<span class="text-lg font-extrabold text-contrast">
{{ formatMessage(mode === 'edit' ? messages.editSkinTitle : messages.addSkinTitle) }}
{{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }}
</span>
</template>
<div class="flex flex-col md:flex-row gap-6">
<div class="h-[25rem] w-[16rem] min-w-[16rem] flex-shrink-0 md:self-center">
<SkinPreviewRenderer
:variant="variant"
:texture-src="previewSkin || ''"
:ears-texture-src="uploadedTextureUrl?.original ?? currentSkin?.texture"
:cape-src="selectedCapeTexture"
framing="modal"
:initial-rotation="Math.PI / 8"
class="h-full w-full"
/>
<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>
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
<section v-if="mode === 'edit' && canEditTextureAndModel">
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.textureSection) }}</h2>
<section>
<h2 class="text-base font-semibold mb-2">Texture</h2>
<ButtonStyled>
<button class="!shadow-none" @click="openTextureFileBrowser">
<UploadIcon /> {{ formatMessage(messages.replaceTextureButton) }}
</button>
<button @click="openUploadSkinModal"><UploadIcon /> Replace texture</button>
</ButtonStyled>
<input
ref="textureFileInput"
type="file"
accept="image/png"
class="hidden"
@change="onTextureFileInputChange"
/>
</section>
<section v-if="canEditTextureAndModel">
<h2 class="text-base font-semibold mb-2">
{{ formatMessage(messages.armStyleSection) }}
</h2>
<section>
<h2 class="text-base font-semibold mb-2">Arm style</h2>
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
<template #default="{ item }">
{{
formatMessage(item === 'CLASSIC' ? messages.wideArmStyle : messages.slimArmStyle)
}}
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
</template>
</RadioButtons>
</section>
<section>
<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"
<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)"
>
<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>
<span>Use default cape</span>
</CapeButton>
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
<span>Use default cape</span>
</CapeLikeTextButton>
<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"
<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"
>
<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>
<template #icon><ChevronRightIcon /></template>
<span>More</span>
</CapeLikeTextButton>
</div>
</section>
</div>
</div>
<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>
<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>
<script setup lang="ts">
import { CheckIcon, SaveIcon, SpinnerIcon, UploadIcon, XIcon } from '@modrinth/assets'
import {
CheckIcon,
ChevronRightIcon,
SaveIcon,
SpinnerIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
CapeButton,
CapeLikeTextButton,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
RadioButtons,
SkinPreviewRenderer,
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { arrayBufferToBase64 } from '@modrinth/utils'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { computed, 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,
normalize_skin_texture,
save_custom_skin,
remove_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 textureFileInput = useTemplateRef<HTMLInputElement>('textureFileInput')
const capeListRef = ref<HTMLElement | null>(null)
const capeListMaxHeight = ref(`${CAPE_LIST_MAX_HEIGHT}px`)
const selectCapeModal = useTemplateRef('selectCapeModal')
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)
@@ -244,49 +151,10 @@ const previewSkin = ref<string>('')
const variant = ref<SkinModel>('CLASSIC')
const selectedCape = ref<Cape | undefined>(undefined)
const props = defineProps<{ capes?: Cape[] }>()
const props = defineProps<{ capes?: Cape[]; defaultCape?: Cape }>()
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
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 visibleCapeList = ref<Cape[]>([])
const sortedCapes = computed(() => {
return [...(props.capes || [])].sort((a, b) => {
@@ -296,6 +164,32 @@ 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
@@ -327,13 +221,9 @@ const disableSave = computed(
)
const saveTooltip = computed(() => {
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)
}
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!'
return undefined
})
@@ -344,13 +234,11 @@ 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
@@ -361,11 +249,12 @@ 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) {
@@ -374,54 +263,98 @@ 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 setUploadedTexture(skinTextureUrl: SkinTextureUrl) {
async function restoreWithNewTexture(skinTextureUrl: SkinTextureUrl) {
uploadedTextureUrl.value = skinTextureUrl
await loadPreviewSkin()
nextTick(() => refreshCapeListLayout())
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
}
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 openTextureFileBrowser() {
textureFileInput.value?.click()
function handleCapeSelected(cape: Cape | undefined) {
selectCape(cape)
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)
}
}
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 = ''
}
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)
}
}
@@ -437,45 +370,17 @@ async function save() {
textureUrl = currentSkin.value!.texture
}
await unequip_skin()
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
if (mode.value === 'new') {
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,
})
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
emit('saved')
} else {
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!,
})
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
await remove_custom_skin(currentSkin.value!)
emit('saved')
}
hide()
@@ -488,53 +393,28 @@ async function save() {
watch([uploadedTextureUrl, currentSkin], async () => {
await loadPreviewSkin()
refreshCapeListLayout()
})
watch(
() => props.capes,
() => {
nextTick(() => refreshCapeListLayout())
initVisibleCapeList()
},
{ 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', options: { applied: boolean; skin?: Skin; previousSkin?: Skin }): void
(event: 'saved'): void
(event: 'deleted', skin: Skin): void
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
}>()
defineExpose({
show,
showNew,
restoreWithNewTexture,
hide,
shouldRestoreModal,
restoreModal,
})
</script>
@@ -0,0 +1,141 @@
<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>
@@ -0,0 +1,141 @@
<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>
@@ -1,578 +0,0 @@
<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 { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import { get_by_profile_path } from '@/helpers/process'
import { kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { showInstanceInFolder } from '@/helpers/utils'
import { showProfileInFolder } 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.link)
const loadingModpack = ref(!!props.instance.linked_data)
const modpack = ref()
if (props.instance.link) {
if (props.instance.linked_data) {
nextTick().then(async () => {
modpack.value = await get_project(props.instance.link?.project_id, 'must_revalidate')
modpack.value = await get_project(props.instance.linked_data?.project_id, 'must_revalidate')
loadingModpack.value = false
})
}
@@ -80,10 +80,9 @@ const playing = ref(false)
const play = async (event: MouseEvent) => {
event?.stopPropagation()
if (props.instance.quarantined) return
loading.value = true
await run(props.instance.id)
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
await run(props.instance.path)
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
.finally(() => {
trackEvent('InstanceStart', {
loader: props.instance.loader,
@@ -98,7 +97,7 @@ const play = async (event: MouseEvent) => {
const stop = async (event: MouseEvent) => {
event?.stopPropagation()
loading.value = true
await kill(props.instance.id).catch(handleError)
await kill(props.instance.path).catch(handleError)
trackEvent('InstanceStop', {
loader: props.instance.loader,
game_version: props.instance.game_version,
@@ -113,7 +112,7 @@ const unlistenProcesses = await process_listener(async () => {
})
const checkProcess = async () => {
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError)
playing.value = runningProcesses.length > 0
}
@@ -131,7 +130,7 @@ onUnmounted(() => {
<template #clickable>
<router-link
class="no-click-animation"
:to="`/instance/${encodeURIComponent(instance.id)}`"
:to="`/instance/${encodeURIComponent(instance.path)}`"
/>
</template>
<div
@@ -139,7 +138,7 @@ onUnmounted(() => {
>
<Avatar
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
:tint-by="instance.id"
:tint-by="instance.path"
size="48px"
/>
<div class="flex flex-col col-span-2 justify-between h-full">
@@ -193,14 +192,8 @@ onUnmounted(() => {
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="
instance.quarantined
? 'This instance has been locked'
: playing
? 'Instance is already open'
: null
"
:disabled="instance.quarantined || playing || loading"
v-tooltip="playing ? 'Instance is already open' : null"
:disabled="playing || loading"
@click="play"
>
<SpinnerIcon v-if="loading" class="animate-spin" />
@@ -213,12 +206,12 @@ onUnmounted(() => {
:options="[
{
id: 'open-instance',
shown: !!instance.id,
action: () => router.push(encodeURI(`/instance/${instance.id}`)),
shown: !!instance.path,
action: () => router.push(encodeURI(`/instance/${instance.path}`)),
},
{
id: 'open-folder',
action: () => showInstanceInFolder(instance.id),
action: () => showProfileInFolder(instance.path),
},
]"
>
@@ -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 { instance_listener, process_listener } from '@/helpers/events'
import { kill, run } from '@/helpers/instance'
import { process_listener, profile_listener } from '@/helpers/events'
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_instance_protocol_version,
get_profile_protocol_version,
get_recent_worlds,
getWorldIdentifier,
hasServerQuickPlaySupport,
@@ -27,7 +27,7 @@ import {
type ServerWorld,
start_join_server,
start_join_singleplayer_world,
type WorldWithInstance,
type WorldWithProfile,
} 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: WorldWithInstance
world: WorldWithProfile
}
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.id === world.instance_id)
const instance = props.recentInstances.find((instance) => instance.path === world.profile)
if (!instance || !world.last_played) {
return
@@ -115,23 +115,23 @@ async function populateJumpBackIn() {
})
const servers: {
instanceId: string
instancePath: string
address: string
}[] = worldItems
.filter((item) => item.world.type === 'server' && item.instance)
.map((item) => ({
instanceId: item.instance.id,
instancePath: item.instance.path,
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.instanceId))
const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath))
await Promise.all(
[...uniqueServerInstances].map((instanceId) =>
get_instance_protocol_version(instanceId)
.then((protoVer) => (protocolVersions.value[instanceId] = protoVer))
[...uniqueServerInstances].map((path) =>
get_profile_protocol_version(path)
.then((protoVer) => (protocolVersions.value[path] = protoVer))
.catch(() => {
console.error(`Failed to get instance protocol for: ${instanceId} `)
console.error(`Failed to get profile protocol for: ${path} `)
}),
),
)
@@ -145,14 +145,14 @@ async function populateJumpBackIn() {
}
})
servers.forEach(({ instanceId, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instanceId], address),
servers.forEach(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
)
}
const instanceItems: InstanceJumpBackInItem[] = []
for (const instance of props.recentInstances) {
const worldItem = worldItems.find((item) => item.instance.id === instance.id)
const worldItem = worldItems.find((item) => item.instance.path === instance.path)
if ((worldItem && worldItem.last_played.isAfter(TWO_WEEKS_AGO)) || !instance.last_played) {
continue
}
@@ -171,15 +171,14 @@ async function populateJumpBackIn() {
.slice(0, MAX_JUMP_BACK_IN)
}
function refreshServer(address: string, instanceId: string) {
refreshServerData(serverData.value[address], protocolVersions.value[instanceId], address)
function refreshServer(address: string, instancePath: string) {
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
}
async function joinWorld(world: WorldWithInstance, instance?: GameInstance) {
if (instance?.quarantined) return
async function joinWorld(world: WorldWithProfile, instance?: GameInstance) {
console.log(`Joining world ${getWorldIdentifier(world)}`)
if (world.type === 'server') {
await start_join_server(world.instance_id, world.address).catch(handleError)
await start_join_server(world.profile, world.address).catch(handleError)
if (instance) {
trackEvent('InstanceStart', {
loader: instance.loader,
@@ -188,14 +187,13 @@ async function joinWorld(world: WorldWithInstance, instance?: GameInstance) {
})
}
} else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(world.instance_id, world.path).catch(handleError)
await start_join_singleplayer_world(world.profile, world.path).catch(handleError)
}
}
async function playInstance(instance: GameInstance) {
if (instance.quarantined) return
await run(instance.id)
.catch((err) => handleSevereError(err, { instanceId: instance.id }))
await run(instance.path)
.catch((err) => handleSevereError(err, { profilePath: instance.path }))
.finally(() => {
trackEvent('InstanceStart', {
loader: instance.loader,
@@ -212,14 +210,14 @@ async function stopInstance(path: string) {
})
}
const currentInstance = ref<string>()
const currentProfile = ref<string>()
const currentWorld = ref<string>()
const unlistenProcesses = await process_listener(async () => {
await checkProcesses()
})
const unlistenInstances = await instance_listener(async () => {
const unlistenProfiles = await profile_listener(async () => {
await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in')
})
@@ -229,18 +227,18 @@ const runningInstances = ref<string[]>([])
type ProcessMetadata = {
uuid: string
instance_id: string
profile_path: string
start_time: string
}
const checkProcesses = async () => {
const runningProcesses: ProcessMetadata[] = await get_all().catch(handleError)
const runningPaths = runningProcesses.map((x) => x.instance_id)
const runningPaths = runningProcesses.map((x) => x.profile_path)
const stoppedInstances = runningInstances.value.filter((x) => !runningPaths.includes(x))
if (currentInstance.value && stoppedInstances.includes(currentInstance.value)) {
currentInstance.value = undefined
if (currentProfile.value && stoppedInstances.includes(currentProfile.value)) {
currentProfile.value = undefined
currentWorld.value = undefined
}
@@ -254,7 +252,7 @@ onMounted(() => {
onUnmounted(() => {
unlistenProcesses()
unlistenInstances()
unlistenProfiles()
})
</script>
@@ -280,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.id}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
:key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
>
<WorldItem
v-if="item.type === 'world'"
:world="item.world"
:playing-instance="runningInstances.includes(item.instance.id)"
:playing-instance="runningInstances.includes(item.instance.path)"
:playing-world="
currentInstance === item.instance.id && currentWorld === getWorldIdentifier(item.world)
currentProfile === item.instance.path && currentWorld === getWorldIdentifier(item.world)
"
:refreshing="
item.world.type === 'server'
@@ -302,41 +300,40 @@ onUnmounted(() => {
item.world.type === 'singleplayer' &&
hasWorldQuickPlaySupport(gameVersions, item.instance.game_version || '')
"
:quarantined="item.instance.quarantined"
:server-status="
item.world.type === 'server' ? serverData[item.world.address].status : undefined
"
:rendered-motd="
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
"
:current-protocol="protocolVersions[item.instance.id]"
:current-protocol="protocolVersions[item.instance.path]"
:game-mode="
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
"
:instance-id="item.instance.id"
:instance-path="item.instance.path"
:instance-name="item.instance.name"
:instance-icon="item.instance.icon_path"
@refresh="
() =>
item.world.type === 'server'
? refreshServer(item.world.address, item.instance.id)
? refreshServer(item.world.address, item.instance.path)
: {}
"
@update="() => populateJumpBackIn()"
@play="
() => {
currentInstance = item.instance.id
currentProfile = item.instance.path
currentWorld = getWorldIdentifier(item.world)
joinWorld(item.world, item.instance)
}
"
@play-instance="
() => {
currentInstance = item.instance.id
currentProfile = item.instance.path
playInstance(item.instance)
}
"
@stop="() => stopInstance(item.instance.id)"
@stop="() => stopInstance(item.instance.path)"
/>
<InstanceItem v-else :instance="item.instance" :last_played="item.last_played" />
</template>
@@ -2,7 +2,6 @@
import {
ClipboardCopyIcon,
EditIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
IssuesIcon,
@@ -24,7 +23,6 @@ import {
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
OverflowMenu,
SmartClickable,
TagItem,
@@ -41,7 +39,7 @@ import type { Component } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { copyToClipboard, createInstanceShortcut } from '@/helpers/utils'
import { copyToClipboard } from '@/helpers/utils'
import type {
ProtocolVersion,
ServerStatus,
@@ -62,7 +60,6 @@ const formatDateTime = useFormatDateTime({
})
const router = useRouter()
const { addNotification } = injectNotificationManager()
const emit = defineEmits<{
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void
@@ -77,7 +74,6 @@ const props = withDefaults(
startingInstance?: boolean
supportsServerQuickPlay?: boolean
supportsWorldQuickPlay?: boolean
quarantined?: boolean
currentProtocol?: ProtocolVersion | null
highlighted?: boolean
@@ -95,10 +91,9 @@ const props = withDefaults(
managed?: boolean
// Instance
instanceId?: string
instancePath?: string
instanceName?: string
instanceIcon?: string
shortcutInstanceId?: string
}>(),
{
playingInstance: false,
@@ -106,7 +101,6 @@ const props = withDefaults(
startingInstance: false,
supportsServerQuickPlay: true,
supportsWorldQuickPlay: false,
quarantined: false,
currentProtocol: null,
refreshing: false,
@@ -116,10 +110,9 @@ const props = withDefaults(
gameMode: undefined,
managed: false,
instanceId: undefined,
instancePath: undefined,
instanceName: undefined,
instanceIcon: undefined,
shortcutInstanceId: undefined,
},
)
@@ -138,33 +131,6 @@ 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 || props.quarantined) 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: {
@@ -219,10 +185,6 @@ 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',
@@ -247,10 +209,10 @@ const messages = defineMessages({
</script>
<template>
<SmartClickable>
<template v-if="instanceId" #clickable>
<template v-if="instancePath" #clickable>
<router-link
class="no-click-animation"
:to="`/instance/${encodeURIComponent(instanceId)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world))}`"
:to="`/instance/${encodeURIComponent(instancePath)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world))}`"
/>
</template>
<div
@@ -282,7 +244,7 @@ const messages = defineMessages({
</TagItem>
<div
v-if="world.type === 'singleplayer'"
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
class="text-sm text-secondary flex items-center gap-1 font-semibold"
>
<UserIcon
aria-hidden="true"
@@ -362,16 +324,16 @@ const messages = defineMessages({
</template>
<template v-else> {{ formatMessage(messages.notPlayedYet) }} </template>
</div>
<template v-if="instanceId">
<template v-if="instancePath">
<router-link
class="flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
:to="`/instance/${instanceId}`"
:to="`/instance/${instancePath}`"
>
<Avatar
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
size="16px"
:tint-by="instanceId"
:tint-by="instancePath"
class="shrink-0"
/>
<span class="truncate">{{ instanceName }}</span>
@@ -424,26 +386,23 @@ const messages = defineMessages({
<ButtonStyled v-else>
<button
v-tooltip="
quarantined
? 'This instance has been locked'
: world.type === 'server'
? !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: playingOtherWorld
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: null
: !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
world.type === 'server'
? !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: playingOtherWorld
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: null
: !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
"
:disabled="
quarantined ||
playingOtherWorld ||
startingInstance ||
(world.type == 'server' && !supportsServerQuickPlay) ||
@@ -461,14 +420,14 @@ const messages = defineMessages({
:options="[
{
id: 'play-instance',
shown: !!instanceId,
disabled: playingInstance || quarantined,
shown: !!instancePath,
disabled: playingInstance,
action: () => emit('play-instance'),
},
{
id: 'open-instance',
shown: !!instanceId,
action: () => router.push(`/instance/${encodeURIComponent(instanceId)}`),
shown: !!instancePath,
action: () => router.push(encodeURI(`/instance/${instancePath}`)),
},
{
id: 'refresh',
@@ -483,7 +442,7 @@ const messages = defineMessages({
{
id: 'edit',
action: () => emit('edit'),
shown: !instanceId,
shown: !instancePath,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
@@ -498,14 +457,14 @@ const messages = defineMessages({
},
{
divider: true,
shown: !!instanceId,
shown: !!instancePath,
},
{
id: 'dont-show-on-home',
shown: !!instanceId,
shown: !!instancePath,
action: () => {
set_world_display_status(
instanceId,
instancePath,
world.type,
getWorldIdentifier(world),
'hidden',
@@ -514,21 +473,16 @@ const messages = defineMessages({
})
},
},
{
id: 'create-shortcut',
shown: !!shortcutInstanceId && !quarantined,
action: () => createShortcut(),
},
{
divider: true,
shown: !instanceId,
shown: !instancePath,
},
{
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => emit('delete'),
shown: !instanceId,
shown: !instancePath,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
@@ -563,10 +517,6 @@ 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,11 +12,7 @@ import { ref } from 'vue'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import type { GameInstance } from '@/helpers/types'
import {
add_server_to_instance,
type ServerPackStatus,
type ServerWorld,
} from '@/helpers/worlds.ts'
import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -39,8 +35,8 @@ async function addServer(play: boolean) {
const serverName = name.value ? name.value : address.value
const resourcePackStatus = resourcePack.value
const index =
(await add_server_to_instance(
props.instance.id,
(await add_server_to_profile(
props.instance.path,
serverName,
address.value,
resourcePackStatus,

Some files were not shown because too many files have changed in this diff Show More