Compare commits

..

1 Commits

Author SHA1 Message Date
Jai A
96782b52cd
Initial draft 2024-11-08 17:42:41 -08:00
1631 changed files with 55589 additions and 97799 deletions

View File

@ -1,9 +1,3 @@
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
# Windows has stack overflows when calling from Tauri, so we increase compiler size
[target.'cfg(windows)']
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
[build]
rustflags = ["--cfg", "tokio_unstable"]
rustflags = ["-C", "link-args=/STACK:16777220"]

View File

@ -1 +0,0 @@
.gitignore

View File

@ -14,5 +14,5 @@ max_line_length = 100
max_line_length = off
trim_trailing_whitespace = false
[*.{rs,java,kts}]
indent_size = 4
[*.rs]
indent_size = 4

35
.gitattributes vendored
View File

@ -1,35 +0,0 @@
* text=auto eol=lf
# SQLx calculates a checksum of migration scripts at build time to compare
# it with the checksum of the applied migration for the same version at
# runtime, to know if the migration script has been changed, and thus the
# DB schema went out of sync with the code.
#
# However, such checksum treats the script as a raw byte stream, taking
# into account inconsequential differences like different line endings
# in different OSes. When combined with Git's EOL conversion and mixed
# native and cross-compilation scenarios, this leads to existing
# migrations that didn't change having potentially different checksums
# according to the environment they were built in, which can break the
# migration system when deploying the Modrinth App, rendering it
# unusable.
#
# The gitattribute above ensures that all text files are checked out
# with LF line endings, but widely deployed app versions were built
# without this attribute set, which left such line endings variable to
# the platform. Thus, there is no perfect solution to this problem:
# forcing CRLF here would break Linux and macOS users, forcing LF
# breaks Windows users, and leaving it unspecified may still lead to
# line ending differences when cross-compiling from Linux to Windows
# or vice versa, or having Git configured with different line
# conversion settings. Moreover, there is no `eol=native` attribute,
# and using CI-only scripts to convert line endings would make the
# builds differ between CI and most local environments. So, let's pick
# the least bad option: let Git handle line endings using its
# configuration by leaving it unspecified, which works fine as long as
# people don't mess with Git's line ending settings, which is the vast
# majority of cases.
/packages/app-lib/migrations/20240711194701_init.sql !eol
/packages/app-lib/migrations/20240813205023_drop-active-unique.sql !eol
/packages/app-lib/migrations/20240930001852_disable-personalized-ads.sql !eol
/packages/app-lib/migrations/20241222013857_feature-flags.sql !eol

View File

@ -6,7 +6,7 @@ body:
attributes:
label: Please confirm the following.
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true
@ -16,7 +16,7 @@ body:
id: version
attributes:
label: What version of the Modrinth App are you using?
description: Find this in ⚙️ Settings (bottom right) -> After Modrinth App (bottom left)
description: Find this in ⚙️ Settings (bottom right) -> About -> App version.
validations:
required: true
- type: dropdown
@ -56,4 +56,4 @@ body:
label: Additional context
description: Add any other context about the problem here.
validations:
required: false
required: false

View File

@ -1,12 +1,12 @@
name: 🌐 Website bug (modrinth.com)
description: Report an issue on the Modrinth website.
labels: [bug, web]
labels: [bug, frontend]
body:
- type: checkboxes
attributes:
label: Please confirm the following.
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true
@ -49,4 +49,4 @@ body:
label: Additional context
description: Add any other context about the problem here.
validations:
required: false
required: false

View File

@ -1,12 +1,12 @@
name: 🛠️ API issue (api.modrinth.com)
description: Report an issue regarding the Modrinth API.
labels: [bug, backend]
labels: [bug, api]
body:
- type: checkboxes
attributes:
label: Please confirm the following.
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true
@ -38,4 +38,4 @@ body:
label: Additional context
description: Add any other context about the problem here.
validations:
required: false
required: false

View File

@ -7,7 +7,7 @@ body:
attributes:
label: Please confirm the following.
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate feature requests
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) 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
@ -43,4 +43,4 @@ body:
label: Additional context
description: Add any other context or screenshots about the suggested enhancement here.
validations:
required: false
required: false

View File

@ -11,4 +11,4 @@ contact_links:
url: https://roadmap.modrinth.com
- name: 📚 Documentation
about: Useful documentation about Modrinth's API
url: https://docs.modrinth.com
url: https://docs.modrinth.com

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -2,46 +2,42 @@ name: daedalus-docker-build
on:
push:
branches:
- '**'
branches: [ "main" ]
paths:
- .github/workflows/daedalus-docker.yml
- 'apps/daedalus_client/**'
- 'packages/daedalus/**'
pull_request:
types: [opened, synchronize]
types: [ opened, synchronize ]
paths:
- .github/workflows/daedalus-docker.yml
- 'apps/daedalus_client/**'
- 'packages/daedalus/**'
merge_group:
types: [checks_requested]
types: [ checks_requested ]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: actions/checkout@v2
- name: Fetch docker metadata
id: docker_meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v3
with:
images: ghcr.io/modrinth/daedalus
- name: Login to GitHub Images
uses: docker/login-action@v3
-
name: Login to GitHub Images
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
-
name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
file: ./apps/daedalus_client/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=registry,ref=ghcr.io/modrinth/daedalus:main
cache-to: type=inline

View File

@ -21,6 +21,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Docker image from GHCR
run: docker pull ghcr.io/modrinth/daedalus:main

View File

@ -2,44 +2,45 @@ name: docker-build
on:
push:
branches:
- '**'
branches: [ "main" ]
paths:
- .github/workflows/labrinth-docker.yml
- 'apps/labrinth/**'
pull_request:
types: [opened, synchronize]
types: [ opened, synchronize ]
paths:
- .github/workflows/labrinth-docker.yml
- 'apps/labrinth/**'
merge_group:
types: [checks_requested]
types: [ checks_requested ]
jobs:
docker:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./apps/labrinth
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: actions/checkout@v2
- name: Fetch docker metadata
id: docker_meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v3
with:
images: ghcr.io/modrinth/labrinth
- name: Login to GitHub Images
uses: docker/login-action@v3
-
name: Login to GitHub Images
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
-
name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
file: ./apps/labrinth/Dockerfile
context: ./apps/labrinth
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=registry,ref=ghcr.io/modrinth/labrinth:main
cache-to: type=inline
labels: ${{ steps.docker_meta.outputs.labels }}

View File

@ -1,152 +0,0 @@
name: Modrinth App build
on:
push:
branches:
- main
tags:
- 'v*'
paths:
- .github/workflows/theseus-build.yml
- 'apps/app/**'
- 'apps/app-frontend/**'
- 'packages/app-lib/**'
- 'packages/app-macros/**'
- 'packages/assets/**'
- 'packages/ui/**'
- 'packages/utils/**'
workflow_dispatch:
inputs:
sign-windows-binaries:
description: Sign Windows binaries
type: boolean
default: true
required: false
jobs:
build:
name: Build
strategy:
fail-fast: false
matrix:
platform: [macos-latest, windows-latest, ubuntu-22.04]
include:
- platform: macos-latest
artifact-target-name: universal-apple-darwin
- platform: windows-latest
artifact-target-name: x86_64-pc-windows-msvc
- platform: ubuntu-22.04
artifact-target-name: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.platform }}
steps:
- name: 📥 Check out code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 🧰 Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
rustflags: ''
target: ${{ startsWith(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
- name: 🧰 Install pnpm
uses: pnpm/action-setup@v4
- name: 🧰 Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: 🧰 Install Linux build dependencies
if: startsWith(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
- name: 🧰 Setup Dasel
uses: jaxxstorm/action-install-gh-release@v2.1.0
with:
repo: TomWright/dasel
tag: v2.8.1
extension-matching: disable
rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
chmod: 0755
- name: ⚙️ Set application version and environment
shell: bash
run: |
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
echo "Setting application version to $APP_VERSION"
dasel put -f apps/app/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version'
cp packages/app-lib/.env.prod packages/app-lib/.env
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
- name: 🧰 Install dependencies
run: pnpm install
- name: ✍️ Set up Windows code signing
if: startsWith(matrix.platform, 'windows')
shell: bash
run: |
if [ '${{ startsWith(github.ref, 'refs/tags/v') || inputs.sign-windows-binaries }}' = 'true' ]; then
choco install jsign --ignore-dependencies # GitHub runners come with a global Java installation already
else
dasel delete -f apps/app/tauri-release.conf.json 'bundle.windows.signCommand'
fi
- name: 🔨 Build macOS app
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
if: startsWith(matrix.platform, 'macos')
env:
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: 🔨 Build Linux app
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
if: startsWith(matrix.platform, 'ubuntu')
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: 🔨 Build Windows app
run: |
[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_11_X64"
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater'
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
if: startsWith(matrix.platform, 'windows')
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
DIGICERT_ONE_SIGNER_API_KEY: ${{ secrets.DIGICERT_ONE_SIGNER_API_KEY }}
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64 }}
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD }}
- name: 📤 Upload app bundles
uses: actions/upload-artifact@v4
with:
name: App bundle (${{ matrix.artifact-target-name }})
path: |
target/release/bundle/appimage/Modrinth App_*.AppImage*
target/release/bundle/deb/Modrinth App_*.deb*
target/release/bundle/rpm/Modrinth App-*.rpm*
target/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz*
target/universal-apple-darwin/release/bundle/dmg/Modrinth App_*.dmg*
target/release/bundle/nsis/Modrinth App_*-setup.exe*
target/release/bundle/nsis/Modrinth App_*-setup.nsis.zip*

View File

@ -1,118 +1,155 @@
name: Modrinth App release
name: 'Modrinth App build'
on:
push:
branches:
- main
tags:
- 'v*'
paths:
- .github/workflows/app-release.yml
- 'apps/app/**'
- 'apps/app-frontend/**'
- 'packages/app-lib/**'
- 'packages/app-macros/**'
- 'packages/assets/**'
- 'packages/ui/**'
- 'packages/utils/**'
workflow_dispatch:
inputs:
version-tag:
description: Version tag to release to the wide public
type: string
required: true
release-notes:
description: Release notes to include in the Tauri version manifest
default: A new release of the Modrinth App is available!
type: string
required: true
jobs:
release:
name: Release Modrinth App
runs-on: ubuntu-latest
build:
strategy:
fail-fast: false
matrix:
platform: [macos-latest, windows-latest, ubuntu-22.04]
env:
LINUX_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-unknown-linux-gnu)
WINDOWS_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-pc-windows-msvc)
MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME: App bundle (universal-apple-darwin)
LAUNCHER_FILES_BUCKET_BASE_URL: https://launcher-files.modrinth.com
runs-on: ${{ matrix.platform }}
steps:
- name: 📥 Download Modrinth App artifacts
uses: dawidd6/action-download-artifact@v11
- uses: actions/checkout@v4
- name: Rust setup (mac)
if: startsWith(matrix.platform, 'macos')
uses: dtolnay/rust-toolchain@stable
with:
workflow: theseus-build.yml
workflow_conclusion: success
event: push
branch: ${{ inputs.version-tag }}
use_unzip: true
components: rustfmt, clippy
targets: aarch64-apple-darwin, x86_64-apple-darwin
- name: 🛠️ Generate version manifest
env:
VERSION_TAG: ${{ inputs.version-tag }}
RELEASE_NOTES: ${{ inputs.release-notes }}
- name: Rust setup
if: "!startsWith(matrix.platform, 'macos')"
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Setup rust cache
uses: actions/cache@v4
with:
path: |
target/**
!target/*/release/bundle/*/*.dmg
!target/*/release/bundle/*/*.app.tar.gz
!target/*/release/bundle/*/*.app.tar.gz.sig
!target/release/bundle/*/*.dmg
!target/release/bundle/*/*.app.tar.gz
!target/release/bundle/*/*.app.tar.gz.sig
!target/release/bundle/*/*.AppImage
!target/release/bundle/*/*.AppImage.tar.gz
!target/release/bundle/*/*.AppImage.tar.gz.sig
!target/release/bundle/*/*.deb
!target/release/bundle/*/*.rpm
!target/release/bundle/msi/*.msi
!target/release/bundle/msi/*.msi.zip
!target/release/bundle/msi/*.msi.zip.sig
!target/release/bundle/nsis/*.exe
!target/release/bundle/nsis/*.nsis.zip
!target/release/bundle/nsis/*.nsis.zip.sig
key: ${{ runner.os }}-rust-target-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-rust-target-
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm via corepack
shell: bash
run: |
# Reference: https://tauri.app/plugin/updater/#server-support
jq -nc \
--arg versionTag "${VERSION_TAG#v}" \
--arg releaseNotes "$RELEASE_NOTES" \
--rawfile macOsAarch64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \
--rawfile macOsX64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \
--rawfile linuxX64UpdateArtifactSignature "${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/appimage/Modrinth App_${VERSION_TAG#v}_amd64.AppImage.tar.gz.sig" \
--rawfile windowsX64UpdateArtifactSignature "${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/nsis/Modrinth App_${VERSION_TAG#v}_x64-setup.nsis.zip.sig" \
'{
"version": $versionTag,
"notes": $releaseNotes,
"pub_date": now | todateiso8601,
"platforms": {
"darwin-aarch64": {
"signature": $macOsAarch64UpdateArtifactSignature,
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App.app.tar.gz")",
"install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App_" + $versionTag + "_universal.dmg")"]
},
"darwin-x86_64": {
"signature": $macOsX64UpdateArtifactSignature,
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App.app.tar.gz")",
"install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App_" + $versionTag + "_universal.dmg")"]
},
"linux-x86_64": {
"signature": $linuxX64UpdateArtifactSignature,
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.AppImage.tar.gz")",
"install_urls": [
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.deb")",
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.AppImage")",
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App-" + $versionTag + "-1.x86_64.rpm")"
]
},
"windows-x86_64": {
"signature": $windowsX64UpdateArtifactSignature,
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/windows/\("Modrinth App_" + $versionTag + "_x64-setup.nsis.zip")",
"install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/windows/\("Modrinth App_" + $versionTag + "_x64-setup.exe")"]
}
}
}' > updates.json
corepack enable
corepack prepare --activate
echo "Generated manifest for version ${VERSION_TAG}:"
cat updates.json
- name: 📤 Upload release artifacts
env:
VERSION_TAG: ${{ inputs.version-tag }}
AWS_ACCESS_KEY_ID: ${{ secrets.LAUNCHER_FILES_BUCKET_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.LAUNCHER_FILES_BUCKET_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ secrets.LAUNCHER_FILES_BUCKET_NAME }}
AWS_REGION: ${{ secrets.LAUNCHER_FILES_BUCKET_REGION }}
AWS_ENDPOINT_URL: ${{ secrets.LAUNCHER_FILES_BUCKET_ENDPOINT_URL }}
AWS_PAGER: ''
# Work around incompatible checksum behavior with some S3-like object storage providers,
# such as Cloudflare R2. See:
# - https://developers.cloudflare.com/r2/examples/aws/aws-cli/
# - https://developers.cloudflare.com/r2/examples/aws/aws-sdk-java/
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
for macosBundleType in 'macos' 'dmg'; do
aws s3 cp --recursive \
"${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/${macosBundleType}" \
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/macos"
done
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
for linuxBundleType in 'appimage' 'deb' 'rpm'; do
aws s3 cp --recursive \
"${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${linuxBundleType}" \
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/linux"
done
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
for windowsBundleType in 'nsis'; do
aws s3 cp --recursive \
"${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${windowsBundleType}" \
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/windows"
done
- name: install dependencies (ubuntu only)
if: startsWith(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev pkg-config libayatana-appindicator3-dev librsvg2-dev
aws s3 cp updates.json "s3://${AWS_BUCKET}"
- name: Install frontend dependencies
run: pnpm install
- name: build app (macos)
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config "tauri-release.conf.json"
if: startsWith(matrix.platform, 'macos')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: build app
run: pnpm --filter=@modrinth/app run tauri build --config "tauri-release.conf.json"
id: build_os
if: "!startsWith(matrix.platform, 'macos')"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: upload ${{ matrix.platform }}
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.platform }}
path: |
target/*/release/bundle/*/*.dmg
target/*/release/bundle/*/*.app.tar.gz
target/*/release/bundle/*/*.app.tar.gz.sig
target/release/bundle/*/*.dmg
target/release/bundle/*/*.app.tar.gz
target/release/bundle/*/*.app.tar.gz.sig
target/release/bundle/*/*.AppImage
target/release/bundle/*/*.AppImage.tar.gz
target/release/bundle/*/*.AppImage.tar.gz.sig
target/release/bundle/*/*.deb
target/release/bundle/*/*.rpm
target/release/bundle/msi/*.msi
target/release/bundle/msi/*.msi.zip
target/release/bundle/msi/*.msi.zip.sig
target/release/bundle/nsis/*.exe
target/release/bundle/nsis/*.nsis.zip
target/release/bundle/nsis/*.nsis.zip.sig

View File

@ -2,86 +2,79 @@ name: CI
on:
push:
branches: [main]
branches: ["main"]
pull_request:
types: [opened, synchronize]
merge_group:
types: [checks_requested]
types: [ checks_requested ]
jobs:
build:
name: Lint and Test
name: Build, Test, and Lint
runs-on: ubuntu-22.04
env:
# Ensure pnpm output is colored in GitHub Actions logs
FORCE_COLOR: 3
# Make cargo nextest successfully ignore projects without tests
NEXTEST_NO_TESTS: pass
steps:
- name: 📥 Check out code
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: 🧰 Install build dependencies
- name: Cache turbo build setup
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: 🧰 Install pnpm
uses: pnpm/action-setup@v4
- name: 🧰 Setup Node.js
- name: Setup Node.JS environment
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
node-version: 20
- name: 🧰 Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install pnpm via corepack
shell: bash
run: |
corepack enable
corepack prepare --activate
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
with:
rustflags: ''
components: clippy, rustfmt
cache: false
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: 🧰 Setup nextest
uses: taiki-e/install-action@nextest
# 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@v2
with:
tool: sqlx-cli
locked: false
no-default-features: true
features: rustls,postgres
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
- name: 🧰 Install dependencies
- name: Install dependencies
run: pnpm install
- name: ⚙️ Start services
run: docker compose up --wait
- name: Build
run: pnpm build
env:
SQLX_OFFLINE: true
- name: ⚙️ Setup Labrinth environment and database
working-directory: apps/labrinth
run: |
cp .env.local .env
sqlx database setup
- name: Lint
run: pnpm lint
env:
SQLX_OFFLINE: true
- name: ⚙️ Set app environment
working-directory: packages/app-lib
run: cp .env.staging .env
- name: Start docker compose
run: docker compose up -d
- name: 🔍 Lint and test
run: pnpm run ci
- name: 🔍 Verify intl:extract has been run
run: |
pnpm intl:extract
git diff --exit-code --color */*/src/locales/en-US/index.json
- name: Test
run: pnpm test
env:
SQLX_OFFLINE: true
DATABASE_URL: postgresql://labrinth:labrinth@localhost/postgres

View File

@ -3,14 +3,10 @@
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/daedalus_client_new/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/daedalus_client/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/daedalus/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/app-playground/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/app/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />

2
.idea/modules.xml generated
View File

@ -2,7 +2,7 @@
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/code.iml" filepath="$PROJECT_DIR$/.idea/code.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/daedalus.iml" filepath="$PROJECT_DIR$/.idea/daedalus.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated
View File

@ -1,11 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CommitMessageInspectionProfile">
<profile version="1.0">
<inspection_tool class="CommitFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>

1
.nvmrc
View File

@ -1 +0,0 @@
20.19.2

View File

@ -1,8 +1,12 @@
{
"prettier.endOfLine": "lf",
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"editor.detectIndentation": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}

7107
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,240 +1,24 @@
[workspace]
resolver = "2"
resolver = '2'
members = [
"apps/app",
"apps/app-playground",
"apps/daedalus_client",
"apps/labrinth",
"packages/app-lib",
"packages/ariadne",
"packages/daedalus",
'./packages/app-lib',
'./apps/app-playground',
'./apps/app',
'./apps/labrinth',
'./apps/daedalus_client',
'./packages/daedalus',
]
[workspace.package]
edition = "2024"
[workspace.dependencies]
actix-cors = "0.7.1"
actix-files = "0.6.6"
actix-http = "3.11.0"
actix-multipart = "0.7.2"
actix-rt = "2.10.0"
actix-web = "4.11.0"
actix-web-prom = "0.10.0"
actix-ws = "0.3.0"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async_zip = "0.0.17"
async-compression = { version = "0.4.25", default-features = false }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
] }
async-trait = "0.1.88"
async-tungstenite = { version = "0.29.1", default-features = false, features = [
"futures-03-sink",
] }
async-walkdir = "2.1.0"
base64 = "0.22.1"
bitflags = "2.9.1"
bytemuck = "1.23.0"
bytes = "1.10.1"
censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.41"
clap = "4.5.40"
clickhouse = "0.13.3"
color-thief = "0.2.2"
console-subscriber = "0.4.1"
daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0"
data-url = "0.3.1"
deadpool-redis = "0.21.1"
dirs = "6.0.0"
discord-rich-presence = "0.2.5"
dotenv-build = "0.1.1"
dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
encoding_rs = "0.8.35"
enumset = "1.1.6"
flate2 = "1.1.2"
fs4 = { version = "0.13.1", default-features = false }
futures = { version = "0.3.31", default-features = false }
futures-util = "0.3.31"
hashlink = "0.10.0"
heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
hyper = "1.6.0"
hyper-rustls = { version = "0.27.7", default-features = false, features = [
"http1",
"native-tokio",
"ring",
"tls12",
] }
hyper-util = "0.1.14"
iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
indexmap = "2.9.0"
indicatif = "0.17.11"
itertools = "0.14.0"
jemalloc_pprof = "0.7.0"
json-patch = { version = "4.0.0", default-features = false }
lettre = { version = "0.11.17", default-features = false, features = [
"builder",
"hostname",
"pool",
"ring",
"rustls",
"rustls-native-certs",
"smtp-transport",
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.28.0", default-features = false }
murmur2 = "0.1.0"
native-dialog = "0.9.0"
notify = { version = "8.0.0", default-features = false }
notify-debouncer-mini = { version = "0.6.0", default-features = false }
p256 = "0.13.2"
paste = "1.0.15"
phf = { version = "0.12.1", features = ["macros"] }
png = "0.17.16"
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
quick-xml = "0.37.5"
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
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
regex = "1.11.1"
reqwest = { version = "0.12.20", default-features = false }
rgb = "0.8.50"
rust_decimal = { version = "1.37.2", features = [
"serde-with-float",
"serde-with-str",
] }
rust_iso3166 = "0.1.14"
rust-s3 = { version = "0.35.1", default-features = false, features = [
"fail-on-err",
"tags",
"tokio-rustls-tls",
] }
rusty-money = "0.4.1"
sentry = { version = "0.41.0", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
"panic",
"reqwest",
"rustls",
] }
sentry-actix = "0.41.0"
serde = "1.0.219"
serde_bytes = "0.11.17"
serde_cbor = "0.11.2"
serde_ini = "0.2.0"
serde_json = "1.0.140"
serde_with = "3.13.0"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
spdx = "0.10.8"
sqlx = { version = "0.8.6", default-features = false }
sysinfo = { version = "0.35.2", default-features = false }
tar = "0.4.44"
tauri = "2.6.1"
tauri-build = "2.3.0"
tauri-plugin-deep-link = "2.4.0"
tauri-plugin-dialog = "2.3.0"
tauri-plugin-http = "2.5.0"
tauri-plugin-opener = "2.4.0"
tauri-plugin-os = "2.3.0"
tauri-plugin-single-instance = "2.3.0"
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
"rustls-tls",
"zip",
] }
tauri-plugin-window-state = "2.3.0"
tempfile = "3.20.0"
theseus = { path = "packages/app-lib" }
thiserror = "2.0.12"
tikv-jemalloc-ctl = "0.6.0"
tikv-jemallocator = "0.6.0"
tokio = "1.45.1"
tokio-stream = "0.1.17"
tokio-util = "0.7.15"
totp-rs = "5.7.0"
tracing = "0.1.41"
tracing-actix-web = "0.7.18"
tracing-error = "0.2.1"
tracing-subscriber = "0.3.19"
url = "2.5.4"
urlencoding = "2.1.3"
uuid = "1.17.0"
validator = "0.20.0"
webp = { version = "0.3.0", default-features = false }
whoami = "1.6.0"
winreg = "0.55.0"
woothee = "0.13.0"
yaserde = "0.12.0"
zip = { version = "4.2.0", default-features = false, features = [
"bzip2",
"deflate",
"deflate64",
"zstd",
] }
zxcvbn = "3.1.0"
[workspace.lints.clippy]
bool_to_int_with_if = "warn"
borrow_as_ptr = "warn"
cfg_not_test = "warn"
clear_with_drain = "warn"
cloned_instead_of_copied = "warn"
collection_is_never_read = "warn"
dbg_macro = "warn"
default_trait_access = "warn"
explicit_iter_loop = "warn"
filter_map_next = "warn"
flat_map_option = "warn"
format_push_string = "warn"
get_unwrap = "warn"
large_include_file = "warn"
large_stack_arrays = "warn"
manual_assert = "warn"
manual_instant_elapsed = "warn"
manual_is_variant_and = "warn"
manual_let_else = "warn"
map_unwrap_or = "warn"
match_bool = "warn"
needless_collect = "warn"
negative_feature_names = "warn"
non_std_lazy_statics = "warn"
pathbuf_init_then_push = "warn"
read_zero_byte_vec = "warn"
redundant_clone = "warn"
redundant_feature_names = "warn"
redundant_type_annotations = "warn"
todo = "warn"
unnested_or_patterns = "warn"
wildcard_dependencies = "warn"
[workspace.lints.rust]
# Turn warnings into errors by default
warnings = "deny"
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "21db186" }
# Optimize for speed and reduce size on release builds
[profile.release]
opt-level = "s" # Optimize for binary size
strip = true # Remove debug symbols
lto = true # Enables link to optimizations
panic = "abort" # Strip expensive panic clean-up logic
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
panic = "abort" # Strip expensive panic clean-up logic
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
lto = true # Enables link to optimizations
opt-level = "s" # Optimize for binary size
strip = true # Remove debug symbols
[profile.dev.package.sqlx-macros]
opt-level = 3
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "27fb16b" }

View File

@ -1,2 +1 @@
**/dist
*.gltf

View File

@ -1,64 +1,54 @@
{
"name": "@modrinth/app-frontend",
"private": true,
"version": "1.0.0-local",
"version": "0.8.9",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"tsc:check": "vue-tsc --noEmit",
"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",
"test": "vue-tsc --noEmit"
"fix": "eslint . --fix && prettier --write ."
},
"dependencies": {
"@geometrically/minecraft-motd-parser": "^1.1.4",
"@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@sentry/vue": "^8.27.0",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-http": "^2.5.0",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0",
"@tauri-apps/api": "^2.0.0-rc.3",
"@tauri-apps/plugin-dialog": "^2.0.0-rc.0",
"@tauri-apps/plugin-os": "^2.0.0-rc.0",
"@tauri-apps/plugin-shell": "^2.0.0-rc.0",
"@tauri-apps/plugin-updater": "^2.0.0-rc.0",
"@tauri-apps/plugin-window-state": "^2.0.0-rc.0",
"@vintl/vintl": "^4.4.1",
"@vueuse/core": "^11.1.0",
"dayjs": "^1.11.10",
"floating-vue": "^5.2.2",
"ofetch": "^1.3.4",
"pinia": "^2.1.7",
"posthog-js": "^1.158.2",
"three": "^0.172.0",
"vite-svg-loader": "^5.1.0",
"vue": "^3.5.13",
"vue": "^3.4.21",
"vue-multiselect": "3.0.0",
"vue-router": "4.3.0",
"vue-virtual-scroller": "v2.0.0-beta.8"
},
"devDependencies": {
"@eslint/compat": "^1.1.1",
"@formatjs/cli": "^6.2.12",
"@nuxt/eslint-config": "^0.5.6",
"@taijased/vue-render-tracker": "^1.0.7",
"@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.19",
"eslint": "^9.9.1",
"eslint-config-custom": "workspace:*",
"eslint-plugin-turbo": "^2.5.4",
"eslint-plugin-turbo": "^2.1.1",
"postcss": "^8.4.39",
"prettier": "^3.2.5",
"sass": "^1.74.1",
"tailwindcss": "^3.4.4",
"tsconfig": "workspace:*",
"typescript": "^5.5.4",
"vite": "^5.4.6",
"vite": "^5.2.8",
"vue-tsc": "^2.1.6"
},
"packageManager": "pnpm@9.4.0",
"web-types": "../../web-types.json"
"packageManager": "pnpm@9.4.0"
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -1,9 +1,14 @@
export { default as ServerIcon } from './server.svg'
export { default as MinimizeIcon } from './minimize.svg'
export { default as MaximizeIcon } from './maximize.svg'
export { default as SwapIcon } from './arrow-left-right.svg'
export { default as ToggleIcon } from './toggle.svg'
export { default as PackageIcon } from './package.svg'
export { default as VersionIcon } from './milestone.svg'
export { default as MoreIcon } from './more.svg'
export { default as TextInputIcon } from './text-cursor-input.svg'
export { default as AddProjectImage } from './add-project.svg'
export { default as NewInstanceImage } from './new-instance.svg'
export { default as MenuIcon } from './menu.svg'
export { default as BugIcon } from './bug.svg'
export { default as ChatIcon } from './messages-square.svg'

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-lock-open"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-scaling"><path d="M21 3 9 15"/><path d="M12 3H3v18h18v-9"/><path d="M16 3h5v5"/><path d="M14 15H9v-5"/></svg>

Before

Width:  |  Height:  |  Size: 311 B

After

Width:  |  Height:  |  Size: 311 B

View File

Before

Width:  |  Height:  |  Size: 253 B

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 937 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:serif="http://www.serif.com/" version="1.1" viewBox="0 0 1793 199">
<g>
<g id="Layer_1">
<g id="green" fill="var(--color-brand)">
<path d="M1184.1,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"/>
<path d="M1291.1,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"/>
<path d="M1357.2,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"/>
<path d="M1460,165.3l-40.8-95.1h23.2l35.1,83.9h-11.4l36.3-83.9h21.4l-40.8,95.1h-23Z"/>
<path d="M1579.6,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"/>
<path d="M1645.7,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"/>
<path d="M1749.9,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"/>
<g>
<path d="M9.8,143l63.4-38.1-5.8-15.3,18.1-18.6,22.9-4.9,6.6,8.2-10.6,10.7-9.2,2.9-6.6,6.8,3.2,9,6.5,6.9,9.2-2.5,6.6-7.2,14.3-4.5,4.3,9.6-14.8,18.1-24.8,7.8-11.1-12.4-63.6,38.2c-3-3.9-6.5-9.4-8.8-14.7ZM192.8,65.4l-50.4,13.6c2.8,7.4,3.7,11.7,4.5,16.5l50.3-13.6c-.8-5.4-2.2-10.8-4.4-16.5Z" fill-rule="evenodd"/>
<path d="M17.3,106.5c3.6,42.1,38.9,75.2,82,75.2s60.7-18.9,74-46.3l16.4,5.7c-15.8,34.1-50.3,57.9-90.4,57.9S3.6,158.2,0,106.5h17.3ZM.3,89.4C5.3,39.2,47.8,0,99.3,0s99.5,44.6,99.5,99.5-1.1,17.4-3.3,25.5l-16.3-5.7c1.6-6.5,2.4-13.1,2.4-19.8,0-45.4-36.9-82.3-82.3-82.3S22.6,48.7,17.6,89.4H.3Z" fill-rule="evenodd"/>
<path d="M99,51.6c-26.4,0-47.9,21.5-47.9,48s21.5,48,48,48,2.7,0,4-.2l4.8,16.8c-2.9.4-5.8.6-8.8.6-36,0-65.2-29.2-65.2-65.2S63.1,34.4,99,34.4s1.8,0,2.7,0l-2.7,17.1ZM118.6,37.4c26.4,8.3,45.6,33,45.6,62.2s-16.4,50.2-39.8,60l-4.8-16.7c16.2-7.7,27.4-24.2,27.4-43.3s-13-38.1-31.1-44.9l2.7-17.2Z" fill-rule="evenodd"/>
</g>
</g>
<g id="black" fill="currentColor">
<path d="M354.8,69.2c12,0,21.7,3.4,28.6,10.4,7,7.2,10.6,17.5,10.6,31.5v54.8h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,5-6.8,12.2-6.8,21.3v48.5h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,4.8-6.8,12-6.8,21.3v48.5h-22.4v-95.6h21.3v12.2c3.6-4.3,8.1-7.5,13.4-9.8,5.4-2.3,11.3-3.4,17.9-3.4s13.6,1.3,19.2,3.9c5.5,2.9,9.8,6.8,13.1,12,3.9-5,8.9-8.9,15.2-11.8,6.3-2.7,13.1-4.1,20.6-4.1ZM466,167.2c-9.7,0-18.4-2.1-26.1-6.3-7.6-4-13.8-10.1-18.1-17.5-4.5-7.3-6.6-15.7-6.6-25.2s2.1-17.9,6.6-25.2c4.3-7.4,10.6-13.4,18.1-17.4,7.7-4.1,16.5-6.3,26.1-6.3s18.6,2.1,26.3,6.3c7.7,4.1,13.8,10,18.3,17.4,4.3,7.3,6.4,15.7,6.4,25.2s-2.1,17.9-6.4,25.2c-4.5,7.5-10.6,13.4-18.3,17.5-7.7,4.1-16.5,6.3-26.3,6.3h0ZM466,148c8.2,0,15-2.7,20.4-8.2,5.4-5.5,8.1-12.7,8.1-21.7s-2.7-16.1-8.1-21.7c-5.4-5.5-12.2-8.2-20.4-8.2s-15,2.7-20.2,8.2c-5.4,5.5-8.1,12.7-8.1,21.7s2.7,16.1,8.1,21.7c5.2,5.5,12,8.2,20.2,8.2ZM631.5,33.1v132.8h-21.5v-12.3c-3.7,4.4-8.3,7.9-13.6,10.2-5.5,2.3-11.5,3.4-18.1,3.4s-17.4-2-24.7-6.1c-7.3-4.1-13.2-9.8-17.4-17.4-4.1-7.3-6.3-15.9-6.3-25.6s2.1-18.3,6.3-25.6c4.1-7.3,10-13.1,17.4-17.2,7.3-4.1,15.6-6.1,24.7-6.1s12.2,1.1,17.4,3.2c5.2,2.1,9.8,5.4,13.4,9.7v-49h22.4ZM581.1,148c5.4,0,10.2-1.3,14.5-3.8,4.3-2.3,7.7-5.9,10.2-10.4,2.5-4.5,3.8-9.8,3.8-15.7s-1.3-11.3-3.8-15.7c-2.5-4.5-5.9-8.1-10.2-10.6-4.3-2.3-9.1-3.6-14.5-3.6s-10.2,1.3-14.5,3.6c-4.3,2.5-7.7,6.1-10.2,10.6-2.5,4.5-3.8,9.8-3.8,15.7s1.3,11.3,3.8,15.7c2.5,4.5,5.9,8.1,10.2,10.4,4.3,2.5,9.1,3.8,14.5,3.8ZM681.6,84.3c6.4-10,17.7-15,34-15v21.3c-1.7-.3-3.4-.5-5.2-.5-8.8,0-15.6,2.5-20.4,7.5-4.8,5.2-7.3,12.5-7.3,22v46.4h-22.4v-95.6h21.3v14h0ZM734.1,70.3h22.4v95.6h-22.4v-95.6ZM745.4,54.6c-4.1,0-7.5-1.3-10.2-3.9-2.7-2.4-4.2-5.9-4.1-9.5,0-3.8,1.4-7,4.1-9.7,2.7-2.5,6.1-3.8,10.2-3.8s7.5,1.3,10.2,3.6c2.7,2.5,4.1,5.5,4.1,9.3s-1.3,7.2-3.9,9.8c-2.7,2.7-6.3,4.1-10.4,4.1ZM839.5,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4v-95.6h21.3v12.3c3.8-4.5,8.4-7.7,14-10,5.5-2.3,12-3.4,19-3.4ZM964.8,160.7c-2.8,2.2-6,3.9-9.5,4.8-3.9,1.1-7.9,1.6-12,1.6-10.6,0-18.6-2.7-24.3-8.2-5.7-5.5-8.6-13.4-8.6-24v-46h-15.7v-17.9h15.7v-21.8h22.4v21.8h25.6v17.9h-25.6v45.5c0,4.7,1.1,8.2,3.4,10.6,2.3,2.5,5.5,3.8,9.8,3.8s9.1-1.3,12.5-3.9l6.3,15.9ZM1036.9,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4V33.1h22.4v48.3c3.8-3.9,8.2-7,13.8-9.1,5.4-2,11.5-3,18.1-3Z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -2,44 +2,8 @@
@tailwind components;
@tailwind utilities;
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/regular.otf') format('opentype');
}
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: italic;
font-display: swap;
font-weight: 400;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/italic.otf') format('opentype');
}
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold.otf') format('opentype');
}
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: italic;
font-display: swap;
font-weight: 600;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype');
}
.font-minecraft {
font-family: 'bundled-minecraft-font-mrapp', monospace;
}
:root {
font-family: var(--font-standard, sans-serif), sans-serif;
font-family: var(--font-standard);
color-scheme: dark;
--view-width: calc(100% - 5rem);
--expanded-view-width: calc(100% - 13rem);
@ -116,25 +80,19 @@ input {
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: 16px;
border: 3px solid transparent;
opacity: 0.5;
transition: opacity 0.2s ease-in-out;
}
*::-webkit-scrollbar:hover {
opacity: 1;
width: var(--gap-md);
border: 3px solid var(--color-scrollbar);
}
*::-webkit-scrollbar-track {
background: transparent;
background: var(--color-bg);
border: 3px solid var(--color-bg);
}
*::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar);
border-radius: var(--radius-lg);
border: 5px solid transparent;
background-clip: content-box;
border: 3px solid var(--color-bg);
}
.highlighted {
@ -153,8 +111,4 @@ img {
-ms-user-select: none;
}
.card-shadow {
box-shadow: var(--shadow-card);
}
@import '@modrinth/assets/omorphia.scss';

View File

@ -12,7 +12,7 @@ import {
SearchIcon,
XIcon,
} from '@modrinth/assets'
import { Button, DropdownSelect } from '@modrinth/ui'
import { Button, Card, DropdownSelect } from '@modrinth/ui'
import { formatCategoryHeader } from '@modrinth/utils'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import dayjs from 'dayjs'
@ -120,11 +120,12 @@ const handleOptionsClick = async (args) => {
}
const search = ref('')
const group = ref('Group')
const group = ref('Category')
const filters = ref('All profiles')
const sortBy = ref('Name')
const filteredResults = computed(() => {
const instances = props.instances.filter((instance) => {
let instances = props.instances.filter((instance) => {
return instance.name.toLowerCase().includes(search.value.toLowerCase())
})
@ -136,7 +137,7 @@ const filteredResults = computed(() => {
if (sortBy.value === 'Game version') {
instances.sort((a, b) => {
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
return a.game_version.localeCompare(b.game_version)
})
}
@ -158,6 +159,16 @@ const filteredResults = computed(() => {
})
}
if (filters.value === 'Custom instances') {
instances = instances.filter((instance) => {
return !instance.linked_data
})
} else if (filters.value === 'Downloaded modpacks') {
instances = instances.filter((instance) => {
return instance.linked_data
})
}
const instanceMap = new Map()
if (group.value === 'Loader') {
@ -177,7 +188,7 @@ const filteredResults = computed(() => {
instanceMap.get(instance.game_version).push(instance)
})
} else if (group.value === 'Group') {
} else if (group.value === 'Category') {
instances.forEach((instance) => {
if (instance.groups.length === 0) {
instance.groups.push('None')
@ -213,53 +224,58 @@ const filteredResults = computed(() => {
instanceMap.set(entry[0], entry[1])
})
}
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
if (group.value === 'Game version') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
return a[0].localeCompare(b[0], undefined, { numeric: true })
})
instanceMap.clear()
sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1])
})
}
return instanceMap
})
</script>
<template>
<div class="flex gap-2">
<div class="iconified-input flex-1">
<ConfirmModalWrapper
ref="confirmModal"
title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be removed. You will not be able to recover it."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteProfile"
/>
<Card class="header">
<div class="iconified-input">
<SearchIcon />
<input v-model="search" type="text" placeholder="Search" />
<input v-model="search" type="text" placeholder="Search" class="search-input" />
<Button class="r-btn" @click="() => (search = '')">
<XIcon />
</Button>
</div>
<DropdownSelect
v-slot="{ selected }"
v-model="sortBy"
name="Sort Dropdown"
class="max-w-[16rem]"
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
placeholder="Select..."
>
<span class="font-semibold text-primary">Sort by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="group"
class="max-w-[16rem]"
name="Group Dropdown"
:options="['Group', 'Loader', 'Game version', 'None']"
placeholder="Select..."
>
<span class="font-semibold text-primary">Group by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
</div>
<div class="labeled_button">
<span>Sort by</span>
<DropdownSelect
v-model="sortBy"
class="sort-dropdown"
name="Sort Dropdown"
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
placeholder="Select..."
/>
</div>
<div class="labeled_button">
<span>Filter by</span>
<DropdownSelect
v-model="filters"
class="filter-dropdown"
name="Filter Dropdown"
:options="['All profiles', 'Custom instances', 'Downloaded modpacks']"
placeholder="Select..."
/>
</div>
<div class="labeled_button">
<span>Group by</span>
<DropdownSelect
v-model="group"
class="group-dropdown"
name="Group Dropdown"
:options="['Category', 'Loader', 'Game version', 'None']"
placeholder="Select..."
/>
</div>
</Card>
<div
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
key,
@ -282,14 +298,6 @@ const filteredResults = computed(() => {
/>
</section>
</div>
<ConfirmModalWrapper
ref="confirmModal"
title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be removed. You will not be able to recover it."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteProfile"
/>
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
@ -307,6 +315,7 @@ const filteredResults = computed(() => {
flex-direction: column;
align-items: flex-start;
width: 100%;
padding: 1rem;
.divider {
display: flex;
@ -374,9 +383,9 @@ const filteredResults = computed(() => {
.instances {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
width: 100%;
gap: 0.75rem;
gap: 1rem;
margin-right: auto;
scroll-behavior: smooth;
overflow-y: auto;

View File

@ -1,141 +0,0 @@
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useLoading } from '@/store/state.js'
const props = defineProps({
throttle: {
type: Number,
default: 0,
},
duration: {
type: Number,
default: 1000,
},
height: {
type: Number,
default: 2,
},
color: {
type: String,
default: 'var(--loading-bar-gradient)',
},
})
const indicator = useLoadingIndicator({
duration: props.duration,
throttle: props.throttle,
})
onBeforeUnmount(() => indicator.clear)
const loading = useLoading()
watch(loading, (newValue) => {
if (newValue.barEnabled) {
if (newValue.loading) {
indicator.start()
} else {
indicator.finish()
}
}
})
function useLoadingIndicator(opts) {
const progress = ref(0)
const isLoading = ref(false)
const step = computed(() => 10000 / opts.duration)
let _timer = null
let _throttle = null
function start() {
clear()
progress.value = 0
if (opts.throttle) {
_throttle = setTimeout(() => {
isLoading.value = true
_startTimer()
}, opts.throttle)
} else {
isLoading.value = true
_startTimer()
}
}
function finish() {
progress.value = 100
_hide()
}
function clear() {
clearInterval(_timer)
clearTimeout(_throttle)
_timer = null
_throttle = null
}
function _increase(num) {
progress.value = Math.min(100, progress.value + num)
}
function _hide() {
clear()
setTimeout(() => {
isLoading.value = false
setTimeout(() => {
progress.value = 0
}, 400)
}, 500)
}
function _startTimer() {
_timer = setInterval(() => {
_increase(step.value)
}, 100)
}
return { progress, isLoading, start, finish, clear }
}
</script>
<template>
<div
class="loading-indicator-bar"
:style="{
'--_width': `${indicator.progress.value}%`,
'--_height': `${indicator.isLoading.value ? props.height : 0}px`,
'--_opacity': `${indicator.isLoading.value ? 1 : 0}`,
top: `0`,
right: `0`,
left: `${props.offsetWidth}`,
pointerEvents: 'none',
width: `var(--_width)`,
height: `var(--_height)`,
borderRadius: `var(--_height)`,
// opacity: `var(--_opacity)`,
background: `${props.color}`,
backgroundSize: `${(100 / indicator.progress.value) * 100}% auto`,
transition: 'width 0.1s ease-in-out, height 0.1s ease-out',
zIndex: 6,
}"
>
<slot />
</div>
</template>
<style lang="scss" scoped>
.loading-indicator-bar::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
width: var(--_width);
bottom: 0;
background-image: radial-gradient(80% 100% at 20% 0%, var(--color-brand) 0%, transparent 80%);
opacity: calc(var(--_opacity) * 0.1);
z-index: 5;
transition:
width 0.1s ease-in-out,
opacity 0.1s ease-out;
}
</style>

View File

@ -10,6 +10,7 @@ import {
StopCircleIcon,
ExternalIcon,
EyeIcon,
ChevronRightIcon,
} from '@modrinth/assets'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import Instance from '@/components/ui/Instance.vue'
@ -24,8 +25,6 @@ import { showProfileInFolder } from '@/helpers/utils.js'
import { trackEvent } from '@/helpers/analytics'
import { handleSevereError } from '@/store/error.js'
import { install as installVersion } from '@/store/install.js'
import { openUrl } from '@tauri-apps/plugin-opener'
import { HeadingLink } from '@modrinth/ui'
const router = useRouter()
@ -44,9 +43,7 @@ const props = defineProps({
})
const actualInstances = computed(() =>
props.instances.filter(
(x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show,
),
props.instances.filter((x) => x && x.instances && x.instances[0]),
)
const modsRow = ref(null)
@ -168,7 +165,13 @@ const handleOptionsClick = async (args) => {
break
}
case 'open_link':
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: `https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
},
})
break
case 'copy_link':
await navigator.clipboard.writeText(
@ -178,53 +181,26 @@ const handleOptionsClick = async (args) => {
}
}
const maxInstancesPerCompactRow = ref(1)
const maxInstancesPerRow = ref(1)
const maxProjectsPerRow = ref(1)
const calculateCardsPerRow = () => {
if (rows.value.length === 0) {
return
}
// Calculate how many cards fit in one row
const containerWidth = rows.value[0].clientWidth
// Convert container width from pixels to rem
const containerWidthInRem =
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75)
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
if (maxInstancesPerRow.value < 5) {
maxInstancesPerRow.value *= 2
}
if (maxInstancesPerCompactRow.value < 5) {
maxInstancesPerCompactRow.value *= 2
}
if (maxProjectsPerRow.value < 3) {
maxProjectsPerRow.value *= 2
}
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 1) / 11)
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 1) / 19)
}
const rowContainer = ref(null)
const resizeObserver = ref(null)
onMounted(() => {
calculateCardsPerRow()
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
if (rowContainer.value) {
resizeObserver.value.observe(rowContainer.value)
}
window.addEventListener('resize', calculateCardsPerRow)
})
onUnmounted(() => {
window.removeEventListener('resize', calculateCardsPerRow)
if (rowContainer.value) {
resizeObserver.value.unobserve(rowContainer.value)
}
})
</script>
@ -237,26 +213,17 @@ onUnmounted(() => {
proceed-label="Delete"
@proceed="deleteProfile"
/>
<div ref="rowContainer" class="flex flex-col gap-4">
<div class="content">
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
<HeadingLink class="mt-1" :to="row.route">
{{ row.label }}
</HeadingLink>
<section
v-if="row.instance"
ref="modsRow"
class="instances"
:class="{ compact: row.compact }"
>
<div class="header">
<router-link :to="row.route">{{ row.label }}</router-link>
<ChevronRightIcon />
</div>
<section v-if="row.instance" ref="modsRow" class="instances">
<Instance
v-for="(instance, instanceIndex) in row.instances.slice(
0,
row.compact ? maxInstancesPerCompactRow : maxInstancesPerRow,
)"
:key="row.label + instance.path"
v-for="instance in row.instances.slice(0, maxInstancesPerRow)"
:key="(instance?.project_id || instance?.id) + instance.install_stage"
:instance="instance"
:compact="row.compact"
:first="instanceIndex === 0"
@contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)"
/>
</section>
@ -293,6 +260,7 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
width: 100%;
padding: 1rem;
gap: 1rem;
-ms-overflow-style: none;
@ -326,36 +294,31 @@ onUnmounted(() => {
a {
margin: 0;
font-size: var(--font-size-md);
font-size: var(--font-size-lg);
font-weight: bolder;
white-space: nowrap;
color: var(--color-base);
color: var(--color-contrast);
}
svg {
height: 1.25rem;
width: 1.25rem;
color: var(--color-base);
height: 1.5rem;
width: 1.5rem;
color: var(--color-contrast);
}
}
.instances {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
grid-gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-gap: 1rem;
width: 100%;
&.compact {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 0.75rem;
}
}
.projects {
display: grid;
width: 100%;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
grid-gap: 0.75rem;
grid-gap: 1rem;
.item {
width: 100%;

View File

@ -0,0 +1,136 @@
import { computed, defineComponent, h, onBeforeUnmount, ref, watch } from 'vue'
import { useLoading } from '@/store/state.js'
export default defineComponent({
props: {
throttle: {
type: Number,
default: 50,
},
duration: {
type: Number,
default: 500,
},
height: {
type: Number,
default: 3,
},
color: {
type: [String, Boolean],
default:
'repeating-linear-gradient(to right, var(--color-brand) 0%, var(--color-brand) 100%)',
},
offsetWidth: {
type: String,
default: '208px',
},
offsetHeight: {
type: String,
default: '52px',
},
},
setup(props, { slots }) {
const indicator = useLoadingIndicator({
duration: props.duration,
throttle: props.throttle,
})
onBeforeUnmount(() => indicator.clear)
const loading = useLoading()
watch(loading, (newValue) => {
if (newValue.barEnabled) {
if (newValue.loading) {
indicator.start()
} else {
indicator.finish()
}
}
})
return () =>
h(
'div',
{
style: {
position: 'fixed',
top: props.offsetHeight,
right: 0,
left: props.offsetWidth,
pointerEvents: 'none',
width: `calc((100vw - ${props.offsetWidth}) * ${indicator.progress.value / 100})`,
height: `${props.height}px`,
opacity: indicator.isLoading.value ? 1 : 0,
background: props.color || undefined,
backgroundSize: `${(100 / indicator.progress.value) * 100}% auto`,
transition: 'width 0.1s, height 0.4s, opacity 0.4s',
zIndex: 6,
},
},
slots,
)
},
})
function useLoadingIndicator(opts) {
const progress = ref(0)
const isLoading = ref(false)
const step = computed(() => 10000 / opts.duration)
let _timer = null
let _throttle = null
function start() {
clear()
progress.value = 0
if (opts.throttle) {
_throttle = setTimeout(() => {
isLoading.value = true
_startTimer()
}, opts.throttle)
} else {
isLoading.value = true
_startTimer()
}
}
function finish() {
progress.value = 100
_hide()
}
function clear() {
clearInterval(_timer)
clearTimeout(_throttle)
_timer = null
_throttle = null
}
function _increase(num) {
progress.value = Math.min(100, progress.value + num)
}
function _hide() {
clear()
setTimeout(() => {
isLoading.value = false
setTimeout(() => {
progress.value = 0
}, 400)
}, 500)
}
function _startTimer() {
_timer = setInterval(() => {
_increase(step.value)
}, 100)
}
return {
progress,
isLoading,
start,
finish,
clear,
}
}

View File

@ -2,21 +2,19 @@
<div
v-if="mode !== 'isolated'"
ref="button"
class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
v-tooltip.right="'Minecraft accounts'"
class="button-base avatar-button"
:class="{ expanded: mode === 'expanded' }"
@click="toggleMenu"
>
<Avatar
size="36px"
:size="mode === 'expanded' ? 'xs' : 'sm'"
:src="
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
selectedAccount
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
"
/>
<div class="flex flex-col w-full">
<span>{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}</span>
<span class="text-secondary text-xs">Minecraft account</span>
</div>
<DropdownIcon class="w-5 h-5 shrink-0" />
</div>
<transition name="fade">
<Card
@ -26,40 +24,28 @@
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
>
<div v-if="selectedAccount" class="selected account">
<Avatar size="xs" :src="avatarUrl" />
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.id}/128`" />
<div>
<h4>{{ selectedAccount.profile.name }}</h4>
<h4>{{ selectedAccount.username }}</h4>
<p>Selected</p>
</div>
<Button
v-tooltip="'Log out'"
icon-only
color="raised"
@click="logout(selectedAccount.profile.id)"
>
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.id)">
<TrashIcon />
</Button>
</div>
<div v-else class="logged-out account">
<h4>Not signed in</h4>
<Button
v-tooltip="'Log in'"
:disabled="loginDisabled"
icon-only
color="primary"
@click="login()"
>
<LogInIcon v-if="!loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
<Button v-tooltip="'Log in'" icon-only color="primary" @click="login()">
<LogInIcon />
</Button>
</div>
<div v-if="displayAccounts.length > 0" class="account-group">
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
<Button class="option account" @click="setAccount(account)">
<Avatar :src="getAccountAvatarUrl(account)" class="icon" />
<p>{{ account.profile.name }}</p>
<Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
<p>{{ account.username }}</p>
</Button>
<Button v-tooltip="'Log out'" icon-only @click="logout(account.profile.id)">
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
<TrashIcon />
</Button>
</div>
@ -73,7 +59,7 @@
</template>
<script setup>
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { PlusIcon, TrashIcon, LogInIcon } from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import {
@ -87,8 +73,7 @@ import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { handleSevereError } from '@/store/error.js'
import { get_available_skins } from '@/helpers/skins'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
defineProps({
mode: {
@ -101,86 +86,32 @@ defineProps({
const emit = defineEmits(['change'])
const accounts = ref({})
const loginDisabled = ref(false)
const defaultUser = ref()
const equippedSkin = ref(null)
const headUrlCache = ref(new Map())
async function refreshValues() {
defaultUser.value = await get_default_user().catch(handleError)
accounts.value = await users().catch(handleError)
try {
const skins = await get_available_skins()
equippedSkin.value = skins.find((skin) => skin.is_equipped)
if (equippedSkin.value) {
try {
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
} catch (error) {
console.warn('Failed to get head render for equipped skin:', error)
}
}
} catch {
equippedSkin.value = null
}
}
function setLoginDisabled(value) {
loginDisabled.value = value
}
defineExpose({
refreshValues,
setLoginDisabled,
loginDisabled,
})
await refreshValues()
const displayAccounts = computed(() =>
accounts.value.filter((account) => defaultUser.value !== account.profile.id),
accounts.value.filter((account) => defaultUser.value !== account.id),
)
const avatarUrl = computed(() => {
if (equippedSkin.value?.texture_key) {
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
if (cachedUrl) {
return cachedUrl
}
return `https://mc-heads.net/avatar/${equippedSkin.value.texture_key}/128`
}
if (selectedAccount.value?.profile?.id) {
return `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128`
}
return 'https://launcher-files.modrinth.com/assets/steve_head.png'
})
function getAccountAvatarUrl(account) {
if (
account.profile.id === selectedAccount.value?.profile?.id &&
equippedSkin.value?.texture_key
) {
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
if (cachedUrl) {
return cachedUrl
}
}
return `https://mc-heads.net/avatar/${account.profile.id}/128`
}
const selectedAccount = computed(() =>
accounts.value.find((account) => account.profile.id === defaultUser.value),
accounts.value.find((account) => account.id === defaultUser.value),
)
async function setAccount(account) {
defaultUser.value = account.profile.id
await set_default_user(account.profile.id).catch(handleError)
defaultUser.value = account.id
await set_default_user(account.id).catch(handleError)
emit('change')
}
async function login() {
loginDisabled.value = true
const loggedIn = await login_flow().catch(handleSevereError)
if (loggedIn) {
@ -189,7 +120,6 @@ async function login() {
}
trackEvent('AccountLogIn')
loginDisabled.value = false
}
const logout = async (id) => {
@ -221,8 +151,13 @@ const handleClickOutside = (event) => {
function toggleMenu(override = true) {
if (showCard.value || !override) {
if (showCard.value) {
show_ads_window()
}
showCard.value = false
} else {
hide_ads_window()
showCard.value = true
}
}
@ -274,11 +209,11 @@ onUnmounted(() => {
}
.account-card {
position: fixed;
position: absolute;
display: flex;
flex-direction: column;
margin-top: 0.5rem;
right: 2rem;
top: 0.5rem;
left: 5.5rem;
z-index: 11;
gap: 0.5rem;
padding: 1rem;
@ -353,17 +288,12 @@ onUnmounted(() => {
.fade-enter-active,
.fade-leave-active {
transition:
opacity 0.25s ease,
translate 0.25s ease,
scale 0.25s ease;
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
translate: 0 -2rem;
scale: 0.9;
}
.avatar-button {
@ -371,10 +301,9 @@ onUnmounted(() => {
align-items: center;
gap: 0.5rem;
color: var(--color-base);
background-color: var(--color-button-bg);
background-color: var(--color-raised-bg);
border-radius: var(--radius-md);
width: 100%;
padding: 0.5rem 0.75rem;
text-align: left;
&.expanded {

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { DropdownIcon, PlusIcon, FolderOpenIcon } from '@modrinth/assets'
import { ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { DropdownIcon, FolderOpenIcon, SearchIcon } from '@modrinth/assets'
import { Button, OverflowMenu } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { add_project_from_path } from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
@ -26,7 +26,7 @@ const handleAddContentFromFile = async () => {
const handleSearchContent = async () => {
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: props.instance.path },
})
}
@ -34,27 +34,30 @@ const handleSearchContent = async () => {
<template>
<div class="joined-buttons">
<ButtonStyled>
<button @click="handleSearchContent">
<PlusIcon />
Install content
</button>
</ButtonStyled>
<ButtonStyled>
<OverflowMenu
:options="[
{
id: 'from_file',
action: handleAddContentFromFile,
},
]"
>
<DropdownIcon />
<template #from_file>
<FolderOpenIcon />
<span class="no-wrap"> Add from file </span>
</template>
</OverflowMenu>
</ButtonStyled>
<Button color="primary" @click="handleSearchContent"><SearchIcon /> Add content </Button>
<OverflowMenu
:options="[
{
id: 'search',
action: handleSearchContent,
},
{
id: 'from_file',
action: handleAddContentFromFile,
},
]"
class="btn btn-primary btn-dropdown-animation icon-only"
>
<DropdownIcon />
<template #search>
<SearchIcon />
<span class="no-wrap"> Search </span>
</template>
<template #from_file>
<FolderOpenIcon />
<span class="no-wrap"> Add from file </span>
</template>
</OverflowMenu>
</div>
</template>

View File

@ -1,43 +1,32 @@
<template>
<div data-tauri-drag-region class="flex items-center gap-1 pl-3">
<Button v-if="false" class="breadcrumbs__back transparent" icon-only @click="$router.back()">
<div class="breadcrumbs">
<Button class="breadcrumbs__back transparent" icon-only @click="$router.back()">
<ChevronLeftIcon />
</Button>
<Button
v-if="false"
class="breadcrumbs__forward transparent"
icon-only
@click="$router.forward()"
>
<Button class="breadcrumbs__forward transparent" icon-only @click="$router.forward()">
<ChevronRightIcon />
</Button>
{{ breadcrumbData.resetToNames(breadcrumbs) }}
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name">
<div v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name" class="breadcrumbs__item">
<router-link
v-if="breadcrumb.link"
:to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id)),
query: breadcrumb.query,
}"
class="text-primary"
>{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name
}}
</router-link>
<span
v-else
data-tauri-drag-region
class="text-contrast font-semibold cursor-default select-none"
>{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name
}}</span
>
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5" />
</template>
<span v-else class="selected">{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name
}}</span>
<ChevronRightIcon v-if="breadcrumb.link" class="chevron" />
</div>
</div>
</template>
@ -61,3 +50,38 @@ const breadcrumbs = computed(() => {
return additionalContext ? [additionalContext, ...route.meta.breadcrumb] : route.meta.breadcrumb
})
</script>
<style scoped lang="scss">
.breadcrumbs {
display: flex;
flex-direction: row;
.breadcrumbs__item {
display: flex;
flex-direction: row;
vertical-align: center;
margin: auto 0;
.chevron,
a {
margin: auto 0;
}
}
.breadcrumbs__back,
.breadcrumbs__forward {
margin: auto 0;
color: var(--color-base);
height: unset;
width: unset;
}
.breadcrumbs__forward {
margin-right: 1rem;
}
}
.selected {
color: var(--color-contrast);
}
</style>

View File

@ -25,6 +25,7 @@
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
const emit = defineEmits(['menu-closed', 'option-clicked'])
@ -37,6 +38,7 @@ const shown = ref(false)
defineExpose({
showMenu: (event, passedItem, passedOptions) => {
hide_ads_window()
item.value = passedItem
options.value = passedOptions
@ -69,6 +71,9 @@ const isLinkedData = (item) => {
}
const hideContextMenu = () => {
if (shown.value) {
show_ads_window()
}
shown.value = false
emit('menu-closed')
}

View File

@ -1,20 +1,11 @@
<script setup>
import {
CheckIcon,
DropdownIcon,
XIcon,
HammerIcon,
LogInIcon,
UpdatedIcon,
CopyIcon,
} from '@modrinth/assets'
import { XIcon, HammerIcon, LogInIcon, UpdatedIcon } from '@modrinth/assets'
import { ChatIcon } from '@/assets/icons'
import { ButtonStyled, Collapsible } from '@modrinth/ui'
import { ref, computed } from 'vue'
import { ref } from 'vue'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleError } from '@/store/notifications.js'
import { handleSevereError } from '@/store/error.js'
import { cancel_directory_change } from '@/helpers/settings.ts'
import { cancel_directory_change } from '@/helpers/settings.js'
import { install } from '@/helpers/profile.js'
import { trackEvent } from '@/helpers/analytics'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
@ -22,7 +13,6 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const errorModal = ref()
const error = ref()
const closable = ref(true)
const errorCollapsed = ref(false)
const title = ref('An error occurred')
const errorType = ref('unknown')
@ -92,7 +82,7 @@ async function loginMinecraft() {
const loggedIn = await login_flow()
if (loggedIn) {
await set_default_user(loggedIn.profile.id).catch(handleError)
await set_default_user(loggedIn.id).catch(handleError)
}
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
@ -128,26 +118,6 @@ async function repairInstance() {
}
loadingRepair.value = false
}
const hasDebugInfo = computed(
() =>
errorType.value === 'directory_move' ||
errorType.value === 'minecraft_auth' ||
errorType.value === 'state_init' ||
errorType.value === 'no_loader_version',
)
const debugInfo = computed(() => error.value.message ?? error.value ?? 'No error message.')
const copied = ref(false)
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 3000)
}
</script>
<template>
@ -219,8 +189,8 @@ async function copyToClipboard(text) {
<template v-else-if="metadata.notEnoughSpace">
<h3>Not enough space</h3>
<p>
It looks like there is not enough space on the disk containing the directory you
selected. Please free up some space and try again or cancel the directory change.
It looks like there is not enough space on the disk containing the dirctory you
selected Please free up some space and try again or cancel the directory change.
</p>
</template>
<template v-else>
@ -274,9 +244,16 @@ async function copyToClipboard(text) {
</div>
</template>
<template v-else>
{{ debugInfo }}
{{ error.message ?? error }}
</template>
<template v-if="hasDebugInfo">
<template
v-if="
errorType === 'directory_move' ||
errorType === 'minecraft_auth' ||
errorType === 'state_init' ||
errorType === 'no_loader_version'
"
>
<hr />
<p>
If nothing is working and you need help, visit
@ -284,39 +261,16 @@ async function copyToClipboard(text) {
and start a chat using the widget in the bottom right and we will be more than happy to
assist! Make sure to provide the following debug information to the agent:
</p>
<details>
<summary>Debug information</summary>
{{ error.message ?? error }}
</details>
</template>
</div>
<div class="flex items-center gap-2">
<ButtonStyled>
<a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a>
</ButtonStyled>
<ButtonStyled v-if="closable">
<button @click="errorModal.hide()"><XIcon /> Close</button>
</ButtonStyled>
<ButtonStyled v-if="hasDebugInfo">
<button :disabled="copied" @click="copyToClipboard(debugInfo)">
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
<template v-else> <CopyIcon /> Copy debug info </template>
</button>
</ButtonStyled>
<div class="input-group push-right">
<a :href="supportLink" class="btn" @click="errorModal.hide()"><ChatIcon /> Get support</a>
<button v-if="closable" class="btn" @click="errorModal.hide()"><XIcon /> Close</button>
</div>
<template v-if="hasDebugInfo">
<div class="bg-button-bg rounded-xl mt-2 overflow-clip">
<button
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
@click="errorCollapsed = !errorCollapsed"
>
<span class="text-contrast font-extrabold m-0">Debug information:</span>
<DropdownIcon
class="h-5 w-5 text-secondary transition-transform"
:class="{ 'rotate-180': !errorCollapsed }"
/>
</button>
<Collapsible :collapsed="errorCollapsed">
<pre class="m-0 px-4 py-3 bg-bg rounded-none">{{ debugInfo }}</pre>
</Collapsible>
</div>
</template>
</div>
</ModalWrapper>
</template>
@ -369,6 +323,7 @@ async function copyToClipboard(text) {
display: flex;
flex-direction: column;
gap: var(--gap-md);
padding: var(--gap-lg);
}
.markdown-body {

View File

@ -151,7 +151,7 @@ const exportPack = async () => {
</div>
</div>
<div v-if="showingFiles" class="table-content">
<div v-for="[path, children] in folders" :key="path.name" class="table-row">
<div v-for="[path, children] of folders" :key="path.name" class="table-row">
<div class="table-cell file-entry">
<div class="file-primary">
<Checkbox
@ -211,6 +211,7 @@ const exportPack = async () => {
<style scoped lang="scss">
.modal-body {
padding: var(--gap-xl);
display: flex;
flex-direction: column;
gap: var(--gap-md);
@ -285,7 +286,6 @@ const exportPack = async () => {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.textarea-wrapper {

View File

@ -1,26 +1,16 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onUnmounted, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import {
DownloadIcon,
GameIcon,
PlayIcon,
SpinnerIcon,
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, useRelativeTime } from '@modrinth/ui'
import { StopCircleIcon, PlayIcon } from '@modrinth/assets'
import { Card, Avatar, AnimatedLogo } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { finish_install, kill, run } from '@/helpers/profile'
import { kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
import { process_listener } from '@/helpers/events'
import { handleError } from '@/store/state.js'
import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
import { trackEvent } from '@/helpers/analytics'
import dayjs from 'dayjs'
const formatRelativeTime = useRelativeTime()
const props = defineProps({
instance: {
@ -29,31 +19,16 @@ const props = defineProps({
return {}
},
},
compact: {
type: Boolean,
default: false,
},
first: {
type: Boolean,
default: false,
},
})
const playing = ref(false)
const loading = ref(false)
const modLoading = computed(
() =>
loading.value ||
currentEvent.value === 'installing' ||
(currentEvent.value === 'launched' && !playing.value),
)
const installing = computed(() => props.instance.install_stage.includes('installing'))
const installed = computed(() => props.instance.install_stage === 'installed')
const modLoading = computed(() => props.instance.install_stage !== 'installed')
const router = useRouter()
const seeInstance = async () => {
await router.push(`/instance/${encodeURIComponent(props.instance.path)}`)
await router.push(`/instance/${encodeURIComponent(props.instance.path)}/`)
}
const checkProcess = async () => {
@ -64,17 +39,17 @@ const checkProcess = async () => {
const play = async (e, context) => {
e?.stopPropagation()
loading.value = true
await run(props.instance.path)
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
.finally(() => {
trackEvent('InstancePlay', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
})
})
loading.value = false
modLoading.value = true
await run(props.instance.path).catch((err) =>
handleSevereError(err, { profilePath: props.instance.path }),
)
modLoading.value = false
trackEvent('InstancePlay', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
})
}
const stop = async (e, context) => {
@ -90,12 +65,6 @@ const stop = async (e, context) => {
})
}
const repair = async (e) => {
e?.stopPropagation()
await finish_install(props.instance)
}
const openFolder = async () => {
await showProfileInFolder(props.instance.path)
}
@ -116,134 +85,175 @@ defineExpose({
instance: props.instance,
})
const currentEvent = ref(null)
const unlisten = await process_listener((e) => {
if (e.profile_path_id === props.instance.path) {
currentEvent.value = e.event
if (e.event === 'finished') {
playing.value = false
}
}
if (e.event === 'finished' && e.profile_path_id === props.instance.path) playing.value = false
})
onMounted(() => checkProcess())
onUnmounted(() => unlisten())
</script>
<template>
<template v-if="compact">
<div
class="card-shadow grid grid-cols-[auto_1fr_auto] bg-bg-raised rounded-xl p-3 pl-4 gap-2 cursor-pointer hover:brightness-90 transition-all"
@click="seeInstance"
@mouseenter="checkProcess"
>
<div class="instance">
<Card class="instance-card-item button-base" @click="seeInstance" @mouseenter="checkProcess">
<Avatar
size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.path"
size="lg"
:src="props.instance.icon_path ? convertFileSrc(props.instance.icon_path) : null"
alt="Mod card"
class="mod-image"
/>
<div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ instance.name }}</span>
</div>
<div class="flex items-center">
<ButtonStyled v-if="playing" color="red" circular @mousehover="checkProcess">
<button v-tooltip="'Stop'" @click="(e) => stop(e, 'InstanceCard')">
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else-if="modLoading" color="standard" circular>
<button v-tooltip="'Instance is loading...'" disabled>
<SpinnerIcon class="animate-spin" />
</button>
</ButtonStyled>
<ButtonStyled v-else :color="first ? 'brand' : 'standard'" circular>
<button
v-tooltip="'Play'"
@click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<!-- Translate for optical centering -->
<PlayIcon class="translate-x-[1px]" />
</button>
</ButtonStyled>
</div>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon />
<span class="text-sm">
<template v-if="instance.last_played">
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
</template>
<template v-else> Never played </template>
</span>
</div>
</div>
</template>
<div v-else>
<div
class="button-base bg-bg-raised p-4 rounded-xl flex gap-3 group"
@click="seeInstance"
@mouseenter="checkProcess"
>
<div class="relative flex items-center justify-center">
<Avatar
size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.path"
alt="Mod card"
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div class="absolute inset-0 flex items-center justify-center">
<ButtonStyled v-if="playing" size="large" color="red" circular>
<button
v-tooltip="'Stop'"
:class="{ 'scale-100 opacity-100': playing }"
class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<SpinnerIcon
v-else-if="modLoading || installing"
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
class="animate-spin w-8 h-8"
tabindex="-1"
/>
<ButtonStyled v-else-if="!installed" size="large" color="brand" circular>
<button
v-tooltip="'Repair'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => repair(e)"
>
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand" circular>
<button
v-tooltip="'Play'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<PlayIcon class="translate-x-[2px]" />
</button>
</ButtonStyled>
</div>
</div>
<div class="flex flex-col gap-1">
<p class="m-0 text-md font-bold text-contrast leading-tight line-clamp-1">
{{ instance.name }}
<div class="project-info">
<p class="title">{{ props.instance.name }}</p>
<p
v-if="
props.instance.install_stage === 'installing' ||
props.instance.install_stage === 'not_installed' ||
props.instance.install_stage === 'pack_installing'
"
class="description"
>
Installing...
</p>
<p v-else class="description">
{{ props.instance.loader }}
{{ props.instance.game_version }}
</p>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
<GameIcon class="shrink-0" />
<span class="text-sm capitalize">
{{ instance.loader }} {{ instance.game_version }}
</span>
</div>
</div>
</Card>
<div
v-if="playing === true"
class="stop cta button-base"
@click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<StopCircleIcon />
</div>
<div v-else-if="modLoading === true && playing === false" class="cta loading-cta">
<AnimatedLogo class="loading-indicator" />
</div>
<div v-else class="install cta button-base" @click="(e) => play(e, 'InstanceCard')">
<PlayIcon />
</div>
</div>
</template>
<style lang="scss">
.loading-indicator {
width: 2.5rem !important;
height: 2.5rem !important;
svg {
width: 2.5rem !important;
height: 2.5rem !important;
}
}
</style>
<style lang="scss" scoped>
.instance {
position: relative;
&:hover {
.cta {
opacity: 1;
bottom: calc(var(--gap-md) + 4.25rem);
}
}
}
.cta {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
z-index: 1;
width: 3rem;
height: 3rem;
right: calc(var(--gap-md) * 2);
bottom: 3.25rem;
opacity: 0;
transition:
0.2s ease-in-out bottom,
0.2s ease-in-out opacity,
0.1s ease-in-out filter !important;
cursor: pointer;
box-shadow: var(--shadow-floating);
svg {
color: var(--color-accent-contrast);
width: 1.5rem !important;
height: 1.5rem !important;
}
&.install {
background: var(--color-brand);
display: flex;
}
&.stop {
background: var(--color-red);
display: flex;
}
&.loading-cta {
background: hsl(220, 11%, 10%) !important;
display: flex;
justify-content: center;
align-items: center;
}
}
.instance-card-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
padding: var(--gap-md);
transition: 0.1s ease-in-out all !important; /* overrides Omorphia defaults */
margin-bottom: 0;
.mod-image {
--size: 100%;
width: 100% !important;
height: auto !important;
max-width: unset !important;
max-height: unset !important;
aspect-ratio: 1 / 1 !important;
}
.project-info {
margin-top: 1rem;
width: 100%;
.title {
color: var(--color-contrast);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
width: 100%;
margin: 0;
font-weight: 600;
font-size: 1rem;
line-height: 110%;
display: inline-block;
}
.description {
color: var(--color-base);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-weight: 500;
font-size: 0.775rem;
line-height: 125%;
margin: 0.25rem 0 0;
text-transform: capitalize;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<ModalWrapper ref="modal" header="Creating an instance">
<ModalWrapper ref="modal" header="Create instance">
<div class="modal-header">
<Chips v-model="creationType" :items="['custom', 'from file', 'import from launcher']" />
</div>
@ -199,16 +199,16 @@
<script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import {
CodeIcon,
FolderOpenIcon,
FolderSearchIcon,
InfoIcon,
PlusIcon,
UpdatedIcon,
UploadIcon,
XIcon,
CodeIcon,
FolderOpenIcon,
InfoIcon,
FolderSearchIcon,
UpdatedIcon,
} from '@modrinth/assets'
import { Avatar, Button, Checkbox, Chips } from '@modrinth/ui'
import { Avatar, Button, Chips, Checkbox } from '@modrinth/ui'
import { computed, onUnmounted, ref, shallowRef } from 'vue'
import { get_loaders } from '@/helpers/tags'
import { create } from '@/helpers/profile'
@ -218,7 +218,7 @@ import { get_game_versions, get_loader_versions } from '@/helpers/metadata'
import { handleError } from '@/store/notifications.js'
import Multiselect from 'vue-multiselect'
import { trackEvent } from '@/helpers/analytics'
import { create_profile_and_install_from_file } from '@/helpers/pack.js'
import { install_from_file } from '@/helpers/pack.js'
import {
get_default_launcher_path,
get_importable_instances,
@ -237,7 +237,7 @@ const display_icon = ref(null)
const showAdvanced = ref(false)
const creating = ref(false)
const showSnapshots = ref(false)
const creationType = ref('custom')
const creationType = ref('from file')
const isShowing = ref(false)
defineExpose({
@ -263,7 +263,7 @@ defineExpose({
hide()
const { paths } = event.payload
if (paths && paths.length > 0 && paths[0].endsWith('.mrpack')) {
await create_profile_and_install_from_file(paths[0]).catch(handleError)
await install_from_file(paths[0]).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModalFileDrop',
})
@ -305,16 +305,12 @@ const [
get_game_versions().then(shallowRef).catch(handleError),
get_loaders()
.then((value) =>
ref(
value
.filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
),
value
.filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
)
.catch((err) => {
handleError(err)
return ref([])
}),
.then(ref)
.catch(handleError),
])
loaders.value.unshift('vanilla')
@ -423,7 +419,7 @@ const openFile = async () => {
const newProject = await open({ multiple: false })
if (!newProject) return
hide()
await create_profile_and_install_from_file(newProject.path ?? newProject).catch(handleError)
await install_from_file(newProject.path ?? newProject).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModalFileOpen',
@ -529,8 +525,8 @@ const next = async () => {
.modal-body {
display: flex;
flex-direction: column;
padding: var(--gap-lg);
gap: var(--gap-md);
margin-top: var(--gap-lg);
}
.input-label {
@ -599,6 +595,7 @@ const next = async () => {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: var(--gap-lg);
padding-bottom: 0;
}

View File

@ -1,53 +0,0 @@
<script setup lang="ts">
import { convertFileSrc } from '@tauri-apps/api/core'
import { formatCategory } from '@modrinth/utils'
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled } from '@modrinth/ui'
type Instance = {
game_version: string
loader: string
path: string
install_stage: string
icon_path?: string
name: string
}
defineProps<{
instance: Instance
}>()
</script>
<template>
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
<router-link
:to="`/instance/${encodeURIComponent(instance.path)}`"
tabindex="-1"
class="flex flex-col gap-4 text-primary"
>
<span class="flex items-center gap-2">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
:alt="instance.name"
size="48px"
/>
<span class="flex flex-col gap-2">
<span class="font-extrabold bold text-contrast">
{{ instance.name }}
</span>
<span class="text-secondary flex items-center gap-2 font-semibold">
<GameIcon class="h-5 w-5 text-secondary" />
{{ formatCategory(instance.loader) }} {{ instance.game_version }}
</span>
</span>
</span>
</router-link>
<ButtonStyled>
<router-link :to="`/instance/${encodeURIComponent(instance.path)}`">
<LeftArrowIcon /> Back to instance
</router-link>
</ButtonStyled>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -1,5 +1,5 @@
<template>
<ModalWrapper ref="detectJavaModal" header="Select java version" :show-ad-on-close="false">
<ModalWrapper ref="detectJavaModal" header="Select java version">
<div class="auto-detect-modal">
<div class="table">
<div class="table-row table-head">
@ -73,6 +73,8 @@ function setJavaInstall(javaInstall) {
</script>
<style lang="scss" scoped>
.auto-detect-modal {
padding: 1rem;
.table {
.table-row {
grid-template-columns: 1fr 4fr min-content;

View File

@ -28,7 +28,7 @@
</Button>
<Button :disabled="props.disabled" @click="autoDetect">
<SearchIcon />
Detect
Auto detect
</Button>
<Button :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon />
@ -108,6 +108,7 @@ async function testJava() {
testingJava.value = true
testingJavaSuccess.value = await test_jre(
props.modelValue ? props.modelValue.path : '',
1,
props.version,
)
testingJava.value = false
@ -126,7 +127,7 @@ async function handleJavaFileInput() {
const filePath = await open()
if (filePath) {
let result = await get_jre(filePath.path ?? filePath).catch(handleError)
let result = await get_jre(filePath.path ?? filePath)
if (!result) {
result = {
path: filePath.path ?? filePath,
@ -186,7 +187,6 @@ async function reinstallJava() {
.toggle-setting {
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: space-between;
align-items: center;

View File

@ -24,8 +24,6 @@ defineExpose({
},
})
const emit = defineEmits(['finish-install'])
const filteredVersions = computed(() => {
return props.versions
})
@ -36,17 +34,9 @@ const installing = computed(() => props.instance.install_stage !== 'installed')
const inProgress = ref(false)
const switchVersion = async (versionId) => {
modpackVersionModal.value.hide()
inProgress.value = true
await update_managed_modrinth_version(props.instance.path, versionId)
inProgress.value = false
emit('finish-install')
}
const onHide = () => {
if (!inProgress.value) {
emit('finish-install')
}
}
</script>
@ -55,10 +45,9 @@ const onHide = () => {
ref="modpackVersionModal"
class="modpack-version-modal"
header="Change modpack version"
:on-hide="onHide"
>
<div class="modal-body">
<div v-if="instance.linked_data" class="mod-card">
<Card 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" />
@ -70,7 +59,7 @@ const onHide = () => {
v-for="version in filteredVersions"
:key="version.id"
class="table-row with-columns selectable"
@click="$router.push(`/project/${version.project_id}/version/${version.id}`)"
@click="$router.push(`/project/${$route.params.id}/version/${version.id}`)"
>
<div class="table-cell table-text">
<Button
@ -117,7 +106,7 @@ const onHide = () => {
</div>
</div>
</div>
</div>
</Card>
</div>
</ModalWrapper>
</template>
@ -184,6 +173,7 @@ const onHide = () => {
}
.modal-body {
padding: var(--gap-xl);
display: flex;
flex-direction: column;
gap: var(--gap-md);

View File

@ -1,59 +0,0 @@
<template>
<RouterLink
v-if="typeof to === 'string'"
:to="to"
v-bind="$attrs"
:class="{
'router-link-active': isPrimary && isPrimary(route),
'subpage-active': isSubpage && isSubpage(route),
}"
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
>
<slot />
</RouterLink>
<button
v-else
v-bind="$attrs"
class="button-animation border-none text-primary cursor-pointer w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
@click="to"
>
<slot />
</button>
</template>
<script setup lang="ts">
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()
type RouteFunction = (route: RouteLocationNormalizedLoaded) => boolean
defineProps<{
to: (() => void) | string
isPrimary?: RouteFunction
isSubpage?: RouteFunction
highlightOverride?: boolean
}>()
defineOptions({
inheritAttrs: false,
})
</script>
<style lang="scss" scoped>
.router-link-active,
.subpage-active {
svg {
filter: drop-shadow(0 0 0.5rem black);
}
}
.router-link-active {
@apply text-[--color-button-text-selected] bg-[--color-button-bg-selected];
}
.subpage-active {
@apply text-contrast bg-button-bg;
}
</style>

View File

@ -1,160 +0,0 @@
<template>
<nav
class="card-shadow experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<RouterLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="tabLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
:class="`button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full ${activeIndex === index && !subpageSelected ? 'text-button-textSelected' : activeIndex === index && subpageSelected ? 'text-contrast' : 'text-primary'}`"
>
<component :is="link.icon" v-if="link.icon" class="size-5" />
<span class="text-nowrap">{{ link.label }}</span>
</RouterLink>
<div
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'}`"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
</nav>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { useRoute, RouterLink } from 'vue-router'
const route = useRoute()
interface Tab {
label: string
href: string | RouteLocationRaw
shown?: boolean
icon?: unknown
subpages?: string[]
}
const props = defineProps<{
links: Tab[]
query?: string
}>()
const sliderLeft = ref(4)
const sliderTop = ref(4)
const sliderRight = ref(4)
const sliderBottom = ref(4)
const activeIndex = ref(-1)
const oldIndex = ref(-1)
const subpageSelected = ref(false)
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
)
const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
const sliderTopPx = computed(() => `${sliderTop.value}px`)
const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
function pickLink() {
let index = -1
subpageSelected.value = false
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
if (route.path === (typeof link.href === 'string' ? link.href : link.href.path)) {
index = i
break
} else if (link.subpages && link.subpages.some((subpage) => route.path.includes(subpage))) {
index = i
subpageSelected.value = true
break
}
}
activeIndex.value = index
if (activeIndex.value !== -1) {
startAnimation()
} else {
oldIndex.value = -1
sliderLeft.value = 0
sliderRight.value = 0
}
}
const tabLinkElements = ref()
function startAnimation() {
const el = tabLinkElements.value[activeIndex.value].$el
if (!el || !el.offsetParent) return
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
}
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
} else {
const delay = 200
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left
setTimeout(() => {
sliderRight.value = newValues.right
}, delay)
} else {
sliderRight.value = newValues.right
setTimeout(() => {
sliderLeft.value = newValues.left
}, delay)
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top
setTimeout(() => {
sliderBottom.value = newValues.bottom
}, delay)
} else {
sliderBottom.value = newValues.bottom
setTimeout(() => {
sliderTop.value = newValues.top
}, delay)
}
}
}
onMounted(() => {
window.addEventListener('resize', pickLink)
pickLink()
})
onUnmounted(() => {
window.removeEventListener('resize', pickLink)
})
watch(route, () => {
pickLink()
})
</script>
<style scoped>
.navtabs-transition {
/* Delay on opacity is to hide any jankiness as the page loads */
transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s,
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
</style>

View File

@ -1,15 +1,17 @@
<script setup>
import { Avatar, TagItem } from '@modrinth/ui'
import { DownloadIcon, HeartIcon, TagIcon } from '@modrinth/assets'
import { Card, Avatar, Button } from '@modrinth/ui'
import { DownloadIcon, HeartIcon, CalendarIcon } from '@modrinth/assets'
import { formatNumber, formatCategory } from '@modrinth/utils'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useRouter } from 'vue-router'
import { install as installVersion } from '@/store/install.js'
dayjs.extend(relativeTime)
const router = useRouter()
const installing = ref(false)
const props = defineProps({
project: {
@ -20,14 +22,6 @@ const props = defineProps({
},
})
const featuredCategory = computed(() => {
if (props.project.display_categories.includes('optimization')) {
return 'optimization'
}
return props.project.display_categories[0] ?? props.project.categories[0]
})
const toColor = computed(() => {
let color = props.project.color
@ -53,66 +47,197 @@ const toTransparent = computed(() => {
'))'
)
})
const install = async (e) => {
e?.stopPropagation()
installing.value = true
await installVersion(
props.project.project_id,
null,
props.instance ? props.instance.path : null,
'ProjectCard',
() => {
installing.value = false
},
)
}
</script>
<template>
<div
class="card-shadow bg-bg-raised rounded-xl overflow-clip cursor-pointer hover:brightness-90 transition-all"
@click="router.push(`/project/${project.slug}`)"
>
<div
class="w-full aspect-[2/1] bg-cover bg-center bg-no-repeat"
:style="{
'background-color': project.featured_gallery ?? project.gallery[0] ? null : toColor,
'background-image': `url(${
project.featured_gallery ??
project.gallery[0] ??
'https://launcher-files.modrinth.com/assets/maze-bg.png'
})`,
}"
>
<div class="wrapper">
<Card class="project-card button-base" @click="router.push(`/project/${project.slug}`)">
<div
class="badges-wrapper"
:class="{
class="banner"
:style="{
'background-color': project.featured_gallery ?? project.gallery[0] ? null : toColor,
'background-image': `url(${
project.featured_gallery ??
project.gallery[0] ??
'https://launcher-files.modrinth.com/assets/maze-bg.png'
})`,
'no-image': !project.featured_gallery && !project.gallery[0],
}"
:style="{
background: !project.featured_gallery && !project.gallery[0] ? toTransparent : null,
}"
></div>
</div>
<div class="flex flex-col justify-center gap-2 px-4 py-3">
<div class="flex gap-2 items-center">
<Avatar size="48px" :src="project.icon_url" />
<div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ project.title }}</span>
>
<div class="badges">
<div class="badge">
<DownloadIcon />
{{ formatNumber(project.downloads) }}
</div>
<div class="badge">
<HeartIcon />
{{ formatNumber(project.follows) }}
</div>
<div class="badge">
<CalendarIcon />
{{ formatCategory(dayjs(project.date_modified).fromNow()) }}
</div>
</div>
<div
class="badges-wrapper"
:class="{
'no-image': !project.featured_gallery && !project.gallery[0],
}"
:style="{
background: !project.featured_gallery && !project.gallery[0] ? toTransparent : null,
}"
></div>
</div>
<p class="m-0 text-sm font-medium line-clamp-3 leading-tight h-[3.25rem]">
<Avatar class="icon" size="sm" :src="project.icon_url" />
<div class="title">
<div class="title-text">
{{ project.title }}
</div>
<div class="author">by {{ project.author }}</div>
</div>
<div class="description">
{{ project.description }}
</p>
<div class="flex items-center gap-2 text-sm text-secondary font-semibold mt-auto">
<div
class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border"
>
<DownloadIcon />
{{ formatNumber(project.downloads) }}
</div>
<div
class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border"
>
<HeartIcon />
{{ formatNumber(project.follows) }}
</div>
<div class="flex items-center gap-1 pr-2">
<TagIcon />
<TagItem>
{{ formatCategory(featuredCategory) }}
</TagItem>
</div>
</div>
</div>
</Card>
<Button color="primary" class="install" :disabled="installing" @click="install">
<DownloadIcon />
{{ installing ? 'Installing' : 'Install' }}
</Button>
</div>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.wrapper {
position: relative;
aspect-ratio: 1;
&:hover {
.install:enabled {
opacity: 1;
}
}
}
.project-card {
display: grid;
grid-gap: 1rem;
grid-template:
'. . . .' 0
'. icon title .' 3rem
'banner banner banner banner' auto
'. description description .' 3.5rem
'. . . .' 0 / 0 3rem minmax(0, 1fr) 0;
max-width: 100%;
height: 100%;
padding: 0;
margin: 0;
.icon {
grid-area: icon;
}
.title {
max-width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
grid-area: title;
white-space: nowrap;
.title-text {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--font-size-md);
font-weight: bold;
}
}
.author {
font-size: var(--font-size-sm);
grid-area: author;
}
.banner {
grid-area: banner;
background-size: cover;
background-position: center;
position: relative;
.badges-wrapper {
width: 100%;
height: 100%;
display: flex;
position: absolute;
top: 0;
left: 0;
mix-blend-mode: hard-light;
}
.badges {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: var(--gap-sm);
gap: var(--gap-xs);
display: flex;
z-index: 10;
flex-direction: row;
justify-content: flex-end;
align-items: flex-end;
}
}
.description {
grid-area: description;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
}
.badge {
background-color: var(--color-raised-bg);
font-size: var(--font-size-xs);
padding: var(--gap-xs) var(--gap-sm);
border-radius: var(--radius-sm);
svg {
width: 1rem;
height: 1rem;
margin-right: var(--gap-xs);
}
}
.install {
position: absolute;
top: calc(5rem + var(--gap-sm));
right: var(--gap-sm);
z-index: 10;
opacity: 0;
transition: opacity 0.2s ease-in-out;
svg {
width: 1rem;
height: 1rem;
}
}
</style>

View File

@ -1,64 +1,126 @@
<script setup>
import { ref, onMounted } from 'vue'
import { init_ads_window } from '@/helpers/ads.js'
import { ref, onMounted, onUnmounted } from 'vue'
import { get as getCreds } from '@/helpers/mr_auth.js'
import { handleError } from '@/store/notifications.js'
import { get_user } from '@/helpers/cache.js'
import { ChevronRightIcon } from '@modrinth/assets'
import { init_ads_window, open_ads_link, record_ads_click } from '@/helpers/ads.js'
import { listen } from '@tauri-apps/api/event'
const adsWrapper = ref(null)
const showAd = ref(true)
let devicePixelRatioWatcher = null
function initDevicePixelRatioWatcher() {
if (devicePixelRatioWatcher) {
devicePixelRatioWatcher.removeEventListener('change', updateAdPosition)
}
devicePixelRatioWatcher = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
devicePixelRatioWatcher.addEventListener('change', updateAdPosition)
}
onMounted(() => {
updateAdPosition()
window.addEventListener('resize', updateAdPosition)
initDevicePixelRatioWatcher()
defineExpose({
scroll() {
updateAdPosition()
},
})
function updateAdPosition() {
if (adsWrapper.value) {
init_ads_window()
initDevicePixelRatioWatcher()
const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) {
const user = await get_user(creds.user_id).catch(handleError)
const MIDAS_BITFLAG = 1 << 0
if (user && (user.badges & MIDAS_BITFLAG) === MIDAS_BITFLAG) {
showAd.value = false
}
}
const adsWrapper = ref(null)
let resizeObserver
let scrollHandler
let intersectionObserver
let mutationObserver
onMounted(() => {
if (showAd.value) {
updateAdPosition(true)
resizeObserver = new ResizeObserver(() => updateAdPosition())
resizeObserver.observe(adsWrapper.value)
intersectionObserver = new IntersectionObserver(() => updateAdPosition())
intersectionObserver.observe(adsWrapper.value)
mutationObserver = new MutationObserver(() => updateAdPosition())
mutationObserver.observe(adsWrapper.value, { attributes: true, childList: true, subtree: true })
// Add scroll event listener
scrollHandler = () => {
requestAnimationFrame(() => updateAdPosition())
}
window.addEventListener('scroll', scrollHandler, { passive: true })
}
})
function updateAdPosition(overrideShown = false) {
if (adsWrapper.value) {
const rect = adsWrapper.value.getBoundingClientRect()
let y = rect.top + window.scrollY
let height = rect.bottom - rect.top
// Prevent ad from overlaying the app bar
if (y <= 52) {
y = 52
height = rect.bottom - 52
if (height < 0) {
height = 0
y = -1000
}
}
init_ads_window(rect.left + window.scrollX, y, rect.right - rect.left, height, overrideShown)
}
}
async function openPlusLink() {
await record_ads_click()
await open_ads_link('https://modrinth.com/plus', 'https://modrinth.com')
}
const unlisten = await listen('ads-scroll', (event) => {
if (adsWrapper.value) {
adsWrapper.value.parentNode.scrollTop += event.payload.scroll
updateAdPosition()
}
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
if (intersectionObserver) {
intersectionObserver.disconnect()
}
if (mutationObserver) {
mutationObserver.disconnect()
}
if (scrollHandler) {
window.removeEventListener('scroll', scrollHandler)
}
unlisten()
})
</script>
<template>
<div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
<a
href="https://modrinth.gg?from=app-placeholder"
target="_blank"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
>
<img
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp"
alt="Host your next server with Modrinth Servers"
class="hidden light-image rounded-[inherit]"
/>
<img
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp"
alt="Host your next server with Modrinth Servers"
class="dark-image rounded-[inherit]"
/>
</a>
<div
v-if="showAd"
ref="adsWrapper"
class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised cursor-pointer"
>
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
<p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
<button
class="mt-auto items-center gap-1 text-purple hover:underline bg-transparent border-none text-left cursor-pointer outline-none"
@click="openPlusLink"
>
<span>
Support creators and Modrinth ad-free with
<span class="font-bold">Modrinth+</span>
</span>
<ChevronRightIcon class="relative top-[3px] h-5 w-5" />
</button>
</div>
</div>
</template>
<style lang="scss" scoped>
.light,
.light-mode {
.dark-image {
display: none;
}
.light-image {
display: block;
}
}
</style>

View File

@ -1,73 +0,0 @@
<script setup>
import { list } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import dayjs from 'dayjs'
import { onUnmounted, ref } from 'vue'
import { profile_listener } from '@/helpers/events.js'
import NavButton from '@/components/ui/NavButton.vue'
import { Avatar } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { SpinnerIcon } from '@modrinth/assets'
const recentInstances = ref([])
const getInstances = async () => {
const profiles = await list().catch(handleError)
recentInstances.value = profiles
.sort((a, b) => {
const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
const dateBCreated = dayjs(b.created)
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
return dateB - dateA
})
.slice(0, 3)
}
await getInstances()
const unlistenProfile = await profile_listener(async (event) => {
if (event.event !== 'synced') {
await getInstances()
}
})
onUnmounted(() => {
unlistenProfile()
})
</script>
<template>
<NavButton
v-for="instance in recentInstances"
:key="instance.id"
v-tooltip.right="instance.name"
:to="`/instance/${encodeURIComponent(instance.path)}`"
class="relative"
>
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px"
:tint-by="instance.path"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10"
>
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-button-bg"></div>
</template>
<style scoped lang="scss"></style>

View File

@ -1,12 +1,20 @@
<template>
<div class="action-groups">
<ButtonStyled v-if="currentLoadingBars.length > 0" color="brand" type="transparent" circular>
<button ref="infoButton" @click="toggleCard()">
<DownloadIcon />
</button>
</ButtonStyled>
<a href="https://support.modrinth.com" class="link">
<ChatIcon />
<span> Get support </span>
</a>
<Button
v-if="currentLoadingBars.length > 0"
ref="infoButton"
icon-only
class="icon-button show-card-icon"
@click="toggleCard()"
>
<DownloadIcon />
</Button>
<div v-if="offline" class="status">
<UnplugIcon />
<span class="circle stopped" />
<div class="running-text">
<span> Offline </span>
</div>
@ -14,10 +22,7 @@
<div v-if="selectedProcess" class="status">
<span class="circle running" />
<div ref="profileButton" class="running-text">
<router-link
class="text-primary"
:to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`"
>
<router-link :to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`">
{{ selectedProcess.profile.name }}
</router-link>
<div
@ -40,6 +45,15 @@
<Button v-tooltip="'View logs'" icon-only class="icon-button" @click="goToTerminal()">
<TerminalSquareIcon />
</Button>
<Button
v-if="currentLoadingBars.length > 0"
ref="infoButton"
icon-only
class="icon-button show-card-icon"
@click="toggleCard()"
>
<DownloadIcon />
</Button>
</div>
<div v-else class="status">
<span class="circle stopped" />
@ -94,14 +108,8 @@
</template>
<script setup>
import {
DownloadIcon,
StopCircleIcon,
TerminalSquareIcon,
DropdownIcon,
UnplugIcon,
} from '@modrinth/assets'
import { Button, ButtonStyled, Card } from '@modrinth/ui'
import { DownloadIcon, StopCircleIcon, TerminalSquareIcon, DropdownIcon } from '@modrinth/assets'
import { Button, Card } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import { loading_listener, process_listener } from '@/helpers/events'
@ -109,6 +117,7 @@ import { useRouter } from 'vue-router'
import { progress_bars_list } from '@/helpers/state.js'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { handleError } from '@/store/notifications.js'
import { ChatIcon } from '@/assets/icons'
import { get_many } from '@/helpers/profile.js'
import { trackEvent } from '@/helpers/analytics'
@ -399,6 +408,10 @@ onBeforeUnmount(() => {
}
}
.show-card-icon {
color: var(--color-brand);
}
.download-enter-active,
.download-leave-active {
transition: opacity 0.3s ease;

View File

@ -1,134 +1,77 @@
<template>
<div
class="card-shadow p-4 bg-bg-raised rounded-xl flex gap-3 group cursor-pointer hover:brightness-90 transition-all"
<Card
class="card button-base"
@click="
() => {
emit('open')
emits('open')
$router.push({
path: `/project/${project.project_id ?? project.id}`,
path: `/project/${project.project_id ?? project.id}/`,
query: { i: props.instance ? props.instance.path : undefined },
})
}
"
>
<div class="icon w-[96px] h-[96px] relative">
<Avatar :src="project.icon_url" size="96px" class="search-icon origin-top transition-all" />
<div class="icon">
<Avatar :src="project.icon_url" size="md" class="search-icon" />
</div>
<div class="flex flex-col gap-2 overflow-hidden">
<div class="gap-2 overflow-hidden no-wrap text-ellipsis">
<span class="text-lg font-extrabold text-contrast m-0 leading-none">
{{ project.title }}
</span>
<span v-if="project.author" class="text-secondary"> by {{ project.author }}</span>
<div class="content-wrapper">
<div class="title joined-text">
<h2>{{ project.title }}</h2>
<span v-if="project.author">by {{ project.author }}</span>
</div>
<div class="m-0 line-clamp-2">
<div class="description">
{{ project.description }}
</div>
<div v-if="categories.length > 0" class="mt-auto flex items-center gap-1 no-wrap">
<TagsIcon class="h-4 w-4 shrink-0" />
<div
v-if="project.project_type === 'mod' || project.project_type === 'modpack'"
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
>
<template v-if="project.client_side === 'optional' && project.server_side === 'optional'">
Client or server
</template>
<template
v-else-if="
(project.client_side === 'optional' || project.client_side === 'required') &&
(project.server_side === 'optional' || project.server_side === 'unsupported')
"
>
Client
</template>
<template
v-else-if="
(project.server_side === 'optional' || project.server_side === 'required') &&
(project.client_side === 'optional' || project.client_side === 'unsupported')
"
>
Server
</template>
<template
v-else-if="
project.client_side === 'unsupported' && project.server_side === 'unsupported'
"
>
Unsupported
</template>
<template
v-else-if="project.client_side === 'required' && project.server_side === 'required'"
>
Client and server
</template>
</div>
<div
v-for="tag in categories"
:key="tag"
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
>
{{ formatCategory(tag.name) }}
</div>
<div class="tags">
<Categories :categories="categories" :type="project.project_type">
<EnvironmentIndicator
:type-only="project.moderation"
:client-side="project.client_side"
:server-side="project.server_side"
:type="project.project_type"
:search="true"
/>
</Categories>
</div>
</div>
<div class="flex flex-col gap-2 items-end shrink-0 ml-auto">
<div class="flex items-center gap-2">
<DownloadIcon class="shrink-0" />
<span>
{{ formatNumber(project.downloads) }}
<span class="text-secondary">downloads</span>
</span>
<div class="stats button-group">
<div v-if="featured" class="badge">
<StarIcon />
Featured
</div>
<div class="flex items-center gap-2">
<HeartIcon class="shrink-0" />
<span>
{{ formatNumber(project.follows ?? project.followers) }}
<span class="text-secondary">followers</span>
</span>
<div class="badge">
<DownloadIcon />
{{ formatNumber(project.downloads) }}
</div>
<div class="mt-auto relative">
<div class="absolute bottom-0 right-0 w-fit">
<ButtonStyled color="brand" type="outlined">
<button
:disabled="installed || installing"
class="shrink-0 no-wrap"
@click.stop="install()"
>
<template v-if="!installed">
<DownloadIcon v-if="modpack || instance" />
<PlusIcon v-else />
</template>
<CheckIcon v-else />
{{
installing
? 'Installing'
: installed
? 'Installed'
: modpack || instance
? 'Install'
: 'Add to an instance'
}}
</button>
</ButtonStyled>
</div>
<div class="badge">
<HeartIcon />
{{ formatNumber(project.follows ?? project.followers) }}
</div>
<div class="badge">
<CalendarIcon />
{{ formatCategory(dayjs(project.date_modified ?? project.updated).fromNow()) }}
</div>
</div>
</div>
<div v-if="project.author" class="install">
<Button color="primary" :disabled="installed || installing" @click.stop="install()">
<DownloadIcon v-if="!installed" />
<CheckIcon v-else />
{{ installing ? 'Installing' : installed ? 'Installed' : 'Install' }}
</Button>
</div>
</Card>
</template>
<script setup>
import { TagsIcon, DownloadIcon, HeartIcon, PlusIcon, CheckIcon } from '@modrinth/assets'
import { ButtonStyled, Avatar } from '@modrinth/ui'
import { DownloadIcon, HeartIcon, CalendarIcon, CheckIcon, StarIcon } from '@modrinth/assets'
import { Avatar, Card, Categories, EnvironmentIndicator, Button } from '@modrinth/ui'
import { formatNumber, formatCategory } from '@modrinth/utils'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { ref, computed } from 'vue'
import { ref } from 'vue'
import { install as installVersion } from '@/store/install.js'
import { useRouter } from 'vue-router'
dayjs.extend(relativeTime)
const router = useRouter()
const props = defineProps({
backgroundImage: {
type: String,
@ -156,26 +99,99 @@ const props = defineProps({
},
})
const emit = defineEmits(['open', 'install'])
const emits = defineEmits(['open'])
const installing = ref(false)
const installed = ref(props.installed)
async function install() {
installing.value = true
await installVersion(
props.project.project_id ?? props.project.id,
props.project.project_id,
null,
props.instance ? props.instance.path : null,
'SearchCard',
() => {
(version) => {
installing.value = false
emit('install', props.project.project_id ?? props.project.id)
},
(profile) => {
router.push(`/instance/${profile}`)
if (props.instance && version) {
installed.value = true
}
},
)
}
const modpack = computed(() => props.project.project_type === 'modpack')
</script>
<style scoped lang="scss">
.icon {
grid-column: 1;
grid-row: 1;
align-self: center;
height: 6rem;
}
.content-wrapper {
display: flex;
justify-content: space-between;
grid-column: 2 / 4;
flex-direction: column;
grid-row: 1;
gap: 0.5rem;
.description {
word-wrap: break-word;
overflow-wrap: anywhere;
}
}
.stats {
grid-column: 1 / 3;
grid-row: 2;
justify-self: stretch;
align-self: start;
}
.install {
grid-column: 3 / 4;
grid-row: 2;
justify-self: end;
align-self: start;
}
.card {
margin-bottom: 0;
display: grid;
grid-template-columns: 6rem auto 7rem;
gap: 0.75rem;
padding: 1rem;
&:active:not(&:disabled) {
scale: 0.98 !important;
}
}
.joined-text {
display: inline-flex;
flex-wrap: wrap;
flex-direction: row;
column-gap: 0.5rem;
align-items: baseline;
overflow: hidden;
text-overflow: ellipsis;
h2 {
margin-bottom: 0 !important;
word-wrap: break-word;
overflow-wrap: anywhere;
}
}
.button-group {
display: inline-flex;
flex-direction: row;
gap: 0.5rem;
align-items: flex-start;
flex-wrap: wrap;
justify-content: flex-start;
}
</style>

View File

@ -72,7 +72,7 @@
</g>
</g>
</svg>
<ProgressBar class="loading-bar" :progress="Math.min(loadingProgress, 100)" />
<ProgressBar class="loading-bar" :progress="loadingProgress" />
<span v-if="message">{{ message }}</span>
</div>
<div class="gradient-bg" data-tauri-drag-region></div>
@ -86,7 +86,8 @@ import { ref, watch } from 'vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { loading_listener } from '@/helpers/events.js'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { XIcon, MaximizeIcon, MinimizeIcon } from '@modrinth/assets'
import { XIcon } from '@modrinth/assets'
import { MaximizeIcon, MinimizeIcon } from '@/assets/icons/index.js'
import { getOS } from '@/helpers/utils.js'
import { useLoading } from '@/store/loading.js'
@ -127,6 +128,7 @@ const os = ref('')
getOS().then((x) => (os.value = x))
loading_listener(async (e) => {
console.log(e)
if (e.event.type === 'directory_move') {
loadingProgress.value = 100 * (e.fraction ?? 1)
message.value = 'Updating app directory...'

View File

@ -71,6 +71,7 @@ async function install() {
align-items: center;
justify-content: center;
gap: var(--gap-md);
padding: var(--gap-lg);
}
.button-row {

View File

@ -1,361 +0,0 @@
<script setup lang="ts">
import { Avatar, ButtonStyled, OverflowMenu, useRelativeTime } from '@modrinth/ui'
import {
UserPlusIcon,
MoreVerticalIcon,
MailIcon,
SettingsIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import { ref, onUnmounted, watch, computed } from 'vue'
import { friend_listener } from '@/helpers/events'
import { friends, friend_statuses, add_friend, remove_friend } from '@/helpers/friends'
import { get_user_many } from '@/helpers/cache'
import { handleError } from '@/store/notifications.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const formatRelativeTime = useRelativeTime()
const props = defineProps<{
credentials: unknown | null
signIn: () => void
}>()
const userCredentials = computed(() => props.credentials)
const search = ref('')
const manageFriendsModal = 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()
}
const friendOptions = ref()
async function handleFriendOptions(args) {
switch (args.option) {
case 'remove-friend':
await removeFriend(args.item)
break
}
}
async function addFriend(friend: Friend) {
await add_friend(
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
).catch(handleError)
await loadFriends()
}
async function removeFriend(friend: Friend) {
await remove_friend(
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
).catch(handleError)
await loadFriends()
}
type Friend = {
id: string
friend_id: string | null
status: string | null
last_updated: Dayjs | null
created: Dayjs
username: string
accepted: boolean
online: boolean
avatar: string
}
const userFriends = ref<Friend[]>([])
const acceptedFriends = computed(() =>
userFriends.value
.filter((x) => x.accepted)
.toSorted((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
return 0 // Both are null, equal in sorting
}
if (a.last_updated === null) {
return 1 // `a` is null, move it after `b`
}
if (b.last_updated === null) {
return -1 // `b` is null, move it after `a`
}
// Both are non-null, sort by date
return b.last_updated.diff(a.last_updated)
}),
)
const pendingFriends = computed(() =>
userFriends.value.filter((x) => !x.accepted).toSorted((a, b) => b.created.diff(a.created)),
)
const loading = ref(true)
async function loadFriends(timeout = false) {
loading.value = timeout
try {
const friendsList = await friends()
if (friendsList.length === 0) {
userFriends.value = []
} else {
const friendStatuses = await friend_statuses()
const users = await get_user_many(
friendsList.map((x) => (x.id === userCredentials.value.user_id ? x.friend_id : x.id)),
)
userFriends.value = friendsList.map((friend) => {
const user = users.find((x) => x.id === friend.id || x.id === friend.friend_id)
const status = friendStatuses.find(
(x) => x.user_id === friend.id || x.user_id === friend.friend_id,
)
return {
id: friend.id,
friend_id: friend.friend_id,
status: status?.profile_name,
last_updated: status && status.last_update ? dayjs(status.last_update) : null,
created: dayjs(friend.created),
avatar: user?.avatar_url,
username: user?.username,
online: !!status,
accepted: friend.accepted,
}
})
}
loading.value = false
} catch (e) {
console.error('Error loading friends', e)
if (timeout) {
setTimeout(() => loadFriends(), 15 * 1000)
}
}
}
watch(
userCredentials,
() => {
if (userCredentials.value === undefined) {
userFriends.value = []
} else if (userCredentials.value === null) {
userFriends.value = []
loading.value = false
} else {
loadFriends(true)
}
},
{ immediate: true },
)
const unlisten = await friend_listener(() => loadFriends())
onUnmounted(() => {
unlisten()
})
</script>
<template>
<ModalWrapper ref="manageFriendsModal" header="Manage friends">
<p v-if="acceptedFriends.length === 0">Add friends to share what you're playing!</p>
<div v-else class="flex flex-col gap-4 min-w-[20rem]">
<input v-model="search" type="text" placeholder="Search friends..." class="w-full" />
<div
v-for="friend in acceptedFriends.filter(
(x) => !search || x.username.toLowerCase().includes(search),
)"
:key="friend.username"
class="flex gap-2 items-center"
>
<div class="relative">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<span
v-if="friend.online"
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div>{{ friend.username }}</div>
<div class="ml-auto">
<ButtonStyled>
<button @click="removeFriend(friend)">
<XIcon />
Remove
</button>
</ButtonStyled>
</div>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="friendInvitesModal" header="View friend requests">
<p v-if="pendingFriends.length === 0">You have no pending friend requests :C</p>
<div v-else class="flex flex-col gap-4">
<div v-for="friend in pendingFriends" :key="friend.username" class="flex gap-2">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<div class="flex flex-col gap-2">
<div>
<p class="m-0">
<template v-if="friend.id === userCredentials.user_id">
<span class="font-bold">{{ friend.username }}</span> sent you a friend request
</template>
<template v-else>
You sent <span class="font-bold">{{ friend.username }}</span> a friend request
</template>
</p>
<p class="m-0 text-sm text-secondary">
{{ formatRelativeTime(friend.created.toISOString()) }}
</p>
</div>
<div class="flex gap-2">
<template v-if="friend.id === userCredentials.user_id">
<ButtonStyled color="brand">
<button @click="addFriend(friend)">
<UserPlusIcon />
Accept
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="removeFriend(friend)">
<XIcon />
Ignore
</button>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled>
<button @click="removeFriend(friend)">
<XIcon />
Cancel
</button>
</ButtonStyled>
</template>
</div>
</div>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="addFriendModal" header="Add a friend">
<div class="mb-4">
<h2 class="m-0 text-lg font-extrabold text-contrast">Username</h2>
<p class="m-0 mt-1 leading-tight">You can add friends with their Modrinth username.</p>
<input v-model="username" class="mt-2 w-full" type="text" placeholder="Enter username..." />
</div>
<ButtonStyled color="brand">
<button :disabled="username.length === 0" @click="addFriendFromModal">
<UserPlusIcon />
Add friend
</button>
</ButtonStyled>
</ModalWrapper>
<div class="flex justify-between items-center">
<h3 class="text-lg m-0">Friends</h3>
<ButtonStyled v-if="userCredentials" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'add-friend',
action: () => addFriendModal.show(),
},
{
id: 'manage-friends',
action: () => manageFriendsModal.show(),
shown: acceptedFriends.length > 0,
},
{
id: 'view-requests',
action: () => friendInvitesModal.show(),
shown: pendingFriends.length > 0,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #add-friend>
<UserPlusIcon aria-hidden="true" />
Add friend
</template>
<template #manage-friends>
<SettingsIcon aria-hidden="true" />
Manage friends
<div
v-if="acceptedFriends.length > 0"
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
>
{{ acceptedFriends.length }}
</div>
</template>
<template #view-requests>
<MailIcon aria-hidden="true" />
View friend requests
<div
v-if="pendingFriends.length > 0"
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
>
{{ pendingFriends.length }}
</div>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<div class="flex flex-col gap-2 mt-2">
<template v-if="loading">
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse">
<div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div>
<div class="flex flex-col w-full">
<div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div>
<div class="h-2.5 bg-button-bg rounded-full w-3/4"></div>
</div>
</div>
</template>
<template v-else-if="acceptedFriends.length === 0">
<div class="text-sm">
<div v-if="!userCredentials">
<span class="text-link cursor-pointer" @click="signIn">Sign in</span> to add friends!
</div>
<div v-else>
<span class="text-link cursor-pointer" @click="addFriendModal.show()">Add friends</span>
to share what you're playing!
</div>
</div>
</template>
<template v-else>
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
<template #remove-friend> <TrashIcon /> Remove friend </template>
</ContextMenu>
<div
v-for="friend in acceptedFriends.slice(0, 5)"
:key="friend.username"
class="flex gap-2 items-center"
:class="{ grayscale: !friend.online }"
@contextmenu.prevent.stop="
(event) =>
friendOptions.showMenu(event, friend, [
{
name: 'remove-friend',
color: 'danger',
},
])
"
>
<div class="relative">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<span
v-if="friend.online"
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span class="text-md m-0 font-medium" :class="{ 'text-secondary': !friend.online }">
{{ friend.username }}
</span>
<span v-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
</div>
</template>
</div>
</template>

View File

@ -7,43 +7,37 @@
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>
<multiselect
v-if="versions?.length > 1"
v-model="selectedVersion"
:options="versions"
:searchable="true"
placeholder="Select version"
open-direction="top"
:show-labels="false"
:custom-label="
(version) =>
`${version?.name} (${version?.loaders
.map((name) => formatCategory(name))
.join(', ')} - ${version?.game_versions.join(', ')})`
"
:max-height="150"
/>
<span v-else>
<span>
{{ selectedVersion?.name }} ({{
selectedVersion?.loaders.map((name) => formatCategory(name)).join(', ')
}}
- {{ selectedVersion?.game_versions.join(', ') }})
</span>
<tr class="header">
<th>{{ instance?.name }}</th>
<th>{{ project.title }}</th>
</tr>
<tr class="content">
<td class="data">{{ instance?.loader }} {{ instance?.game_version }}</td>
<td>
<DropdownSelect
v-if="versions?.length > 1"
v-model="selectedVersion"
:options="versions"
placeholder="Select version"
name="Version select"
:display-name="
(version) =>
`${version?.name} (${version?.loaders
.map((name) => formatCategory(name))
.join(', ')} - ${version?.game_versions.join(', ')})`
"
render-up
/>
<span v-else>
<span>
{{ selectedVersion?.name }} ({{
selectedVersion?.loaders.map((name) => formatCategory(name)).join(', ')
}}
- {{ selectedVersion?.game_versions.join(', ') }})
</span>
</td>
</tr>
</tbody>
</span>
</td>
</tr>
</table>
<div class="button-group">
<Button @click="() => incompatibleModal.hide()"><XIcon />Cancel</Button>
@ -58,13 +52,12 @@
<script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { XIcon, DownloadIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { Button, DropdownSelect } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { add_project_from_version as installMod } from '@/helpers/profile'
import { ref } from 'vue'
import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics'
import Multiselect from 'vue-multiselect'
const instance = ref(null)
const project = ref(null)
@ -76,10 +69,10 @@ const installing = ref(false)
const onInstall = ref(() => {})
defineExpose({
show: (instanceVal, projectVal, projectVersions, selected, callback) => {
show: (instanceVal, projectVal, projectVersions, callback) => {
instance.value = instanceVal
versions.value = projectVersions
selectedVersion.value = selected ?? projectVersions[0]
selectedVersion.value = projectVersions[0]
project.value = projectVal
@ -158,6 +151,7 @@ td:first-child {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
:deep(.animated-dropdown .options) {
max-height: 13.375rem;

View File

@ -1,7 +1,7 @@
<script setup>
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { XIcon, DownloadIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { create_profile_and_install as pack_install } from '@/helpers/pack'
import { install as pack_install } from '@/helpers/pack'
import { ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { handleError } from '@/store/state.js'
@ -13,17 +13,15 @@ const confirmModal = ref(null)
const installing = ref(false)
const onInstall = ref(() => {})
const onCreateInstance = ref(() => {})
defineExpose({
show: (projectVal, versionIdVal, callback, createInstanceCallback) => {
show: (projectVal, versionIdVal, callback) => {
project.value = projectVal
versionId.value = versionIdVal
installing.value = false
confirmModal.value.show()
onInstall.value = callback
onCreateInstance.value = createInstanceCallback
trackEvent('PackInstallStart')
},
@ -38,7 +36,6 @@ async function install() {
versionId.value,
project.value.title,
project.value.icon_url,
onCreateInstance.value,
).catch(handleError)
trackEvent('PackInstall', {
id: project.value.id,
@ -71,5 +68,6 @@ async function install() {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
</style>

View File

@ -243,7 +243,7 @@ const createInstance = async () => {
"
>
<Button
:disabled="profile.installedMod || profile.installing"
:disabled="profile.installedMod || profile.installing || profile.linked_data?.locked"
@click="install(profile)"
>
<DownloadIcon v-if="!profile.installedMod && !profile.installing" />
@ -253,7 +253,9 @@ const createInstance = async () => {
? 'Installing...'
: profile.installedMod
? 'Installed'
: 'Install'
: profile.linked_data && profile.linked_data.locked
? 'Paired'
: 'Install'
}}
</Button>
</div>
@ -306,6 +308,7 @@ const createInstance = async () => {
flex-direction: column;
gap: 1rem;
margin: 0;
padding: 1rem;
background-color: var(--color-bg);
}
@ -357,7 +360,7 @@ const createInstance = async () => {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 350px;
padding: 1rem;
}
.profiles {
@ -378,6 +381,7 @@ const createInstance = async () => {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 0.5rem;
gap: 0.5rem;
img {

View File

@ -1,326 +0,0 @@
<script setup lang="ts">
import { convertFileSrc } from '@tauri-apps/api/core'
import { SpinnerIcon, TrashIcon, UploadIcon, PlusIcon, EditIcon, CopyIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, OverflowMenu, Checkbox } from '@modrinth/ui'
import { computed, ref, type Ref, watch } from 'vue'
import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { trackEvent } from '@/helpers/analytics'
import { open } from '@tauri-apps/plugin-dialog'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { useRouter } from 'vue-router'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import type { InstanceSettingsTabProps, GameInstance } from '../../../helpers/types'
const { formatMessage } = useVIntl()
const router = useRouter()
const deleteConfirmModal = ref()
const props = defineProps<InstanceSettingsTabProps>()
const title = ref(props.instance.name)
const icon: Ref<string | undefined> = ref(props.instance.icon_path)
const groups = ref(props.instance.groups)
const newCategoryInput = ref('')
const installing = computed(() => props.instance.install_stage !== 'installed')
async function duplicateProfile() {
await duplicate(props.instance.path).catch(handleError)
trackEvent('InstanceDuplicate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
})
}
const allInstances = ref((await list()) as GameInstance[])
const availableGroups = computed(() => [
...new Set([...allInstances.value.flatMap((instance) => instance.groups), ...groups.value]),
])
async function resetIcon() {
icon.value = undefined
await edit_icon(props.instance.path, null).catch(handleError)
trackEvent('InstanceRemoveIcon')
}
async function setIcon() {
const value = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'svg', 'webp', 'gif', 'jpg'],
},
],
})
if (!value) return
icon.value = value
await edit_icon(props.instance.path, icon.value).catch(handleError)
trackEvent('InstanceSetIcon')
}
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),
}))
const toggleGroup = (group: string) => {
if (groups.value.includes(group)) {
groups.value = groups.value.filter((x) => x !== group)
} else {
groups.value.push(group)
}
}
const addCategory = () => {
const text = newCategoryInput.value.trim()
if (text.length > 0) {
groups.value.push(text.substring(0, 32))
newCategoryInput.value = ''
}
}
watch(
[title, groups, groups],
async () => {
await edit(props.instance.path, editProfileObject.value)
},
{ deep: true },
)
const removing = ref(false)
async function removeProfile() {
removing.value = true
await remove(props.instance.path).catch(handleError)
removing.value = false
trackEvent('InstanceRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
})
await router.push({ path: '/' })
}
const messages = defineMessages({
name: {
id: 'instance.settings.tabs.general.name',
defaultMessage: 'Name',
},
libraryGroups: {
id: 'instance.settings.tabs.general.library-groups',
defaultMessage: 'Library groups',
},
libraryGroupsDescription: {
id: 'instance.settings.tabs.general.library-groups.description',
defaultMessage:
'Library groups allow you to organize your instances into different sections in your library.',
},
libraryGroupsEnterName: {
id: 'instance.settings.tabs.general.library-groups.enter-name',
defaultMessage: 'Enter group name',
},
libraryGroupsCreate: {
id: 'instance.settings.tabs.general.library-groups.create',
defaultMessage: 'Create new group',
},
editIcon: {
id: 'instance.settings.tabs.general.edit-icon',
defaultMessage: 'Edit icon',
},
selectIcon: {
id: 'instance.settings.tabs.general.edit-icon.select',
defaultMessage: 'Select icon',
},
replaceIcon: {
id: 'instance.settings.tabs.general.edit-icon.replace',
defaultMessage: 'Replace icon',
},
removeIcon: {
id: 'instance.settings.tabs.general.edit-icon.remove',
defaultMessage: 'Remove icon',
},
duplicateInstance: {
id: 'instance.settings.tabs.general.duplicate-instance',
defaultMessage: 'Duplicate instance',
},
duplicateInstanceDescription: {
id: 'instance.settings.tabs.general.duplicate-instance.description',
defaultMessage: 'Creates a copy of this instance, including worlds, configs, mods, etc.',
},
duplicateButtonTooltipInstalling: {
id: 'instance.settings.tabs.general.duplicate-button.tooltip.installing',
defaultMessage: 'Cannot duplicate while installing.',
},
duplicateButton: {
id: 'instance.settings.tabs.general.duplicate-button',
defaultMessage: 'Duplicate',
},
deleteInstance: {
id: 'instance.settings.tabs.general.delete',
defaultMessage: 'Delete instance',
},
deleteInstanceDescription: {
id: 'instance.settings.tabs.general.delete.description',
defaultMessage:
'Permanently deletes an instance from your device, including your worlds, configs, and all installed content. Be careful, as once you delete a instance there is no way to recover it.',
},
deleteInstanceButton: {
id: 'instance.settings.tabs.general.delete.button',
defaultMessage: 'Delete instance',
},
deletingInstanceButton: {
id: 'instance.settings.tabs.general.deleting.button',
defaultMessage: 'Deleting...',
},
})
</script>
<template>
<ConfirmModalWrapper
ref="deleteConfirmModal"
title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be permanently erased, including your worlds. You will not be able to recover it."
:has-to-type="false"
proceed-label="Delete"
:show-ad-on-close="false"
@proceed="removeProfile"
/>
<div class="block">
<div class="float-end ml-4 relative group">
<OverflowMenu
v-tooltip="formatMessage(messages.editIcon)"
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
:options="[
{
id: 'select',
action: () => setIcon(),
},
{
id: 'remove',
color: 'danger',
action: () => resetIcon(),
shown: !!icon,
},
]"
>
<Avatar
:src="icon ? convertFileSrc(icon) : icon"
size="108px"
class="!border-4 group-hover:brightness-75"
:tint-by="props.instance.path"
no-shadow
/>
<div class="absolute top-0 right-0 m-2">
<div
class="p-2 m-0 text-primary flex items-center justify-center aspect-square bg-button-bg rounded-full border-button-border border-solid border-[1px] hovering-icon-shadow"
>
<EditIcon aria-hidden="true" class="h-4 w-4 text-primary" />
</div>
</div>
<template #select>
<UploadIcon />
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
</template>
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
</OverflowMenu>
</div>
<label for="instance-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.name) }}
</label>
<div class="flex">
<input
id="instance-name"
v-model="title"
autocomplete="off"
maxlength="80"
class="flex-grow"
type="text"
/>
</div>
<template v-if="instance.install_stage == 'installed'">
<div>
<h2
id="duplicate-instance-label"
class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block"
>
{{ formatMessage(messages.duplicateInstance) }}
</h2>
<p class="m-0 mb-2">
{{ formatMessage(messages.duplicateInstanceDescription) }}
</p>
</div>
<ButtonStyled>
<button
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
aria-labelledby="duplicate-instance-label"
:disabled="installing"
@click="duplicateProfile"
>
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
</button>
</ButtonStyled>
</template>
<h2 class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.libraryGroups) }}
</h2>
<p class="m-0 mb-2">
{{ formatMessage(messages.libraryGroupsDescription) }}
</p>
<div class="flex flex-col gap-1">
<Checkbox
v-for="group in availableGroups"
:key="group"
:model-value="groups.includes(group)"
:label="group"
@click="toggleGroup(group)"
/>
<div class="flex gap-2 items-center">
<input
v-model="newCategoryInput"
type="text"
:placeholder="formatMessage(messages.libraryGroupsEnterName)"
@submit="() => addCategory"
/>
<ButtonStyled>
<button class="w-fit" @click="() => addCategory()">
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
</button>
</ButtonStyled>
</div>
</div>
<h2 id="delete-instance-label" class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.deleteInstance) }}
</h2>
<p class="m-0 mb-2">
{{ formatMessage(messages.deleteInstanceDescription) }}
</p>
<ButtonStyled color="red">
<button
aria-labelledby="delete-instance-label"
:disabled="removing"
@click="deleteConfirmModal.show()"
>
<SpinnerIcon v-if="removing" class="animate-spin" />
<TrashIcon v-else />
{{
removing
? formatMessage(messages.deletingInstanceButton)
: formatMessage(messages.deleteInstanceButton)
}}
</button>
</ButtonStyled>
</div>
</template>
<style scoped lang="scss">
.hovering-icon-shadow {
box-shadow: var(--shadow-inset-sm), var(--shadow-raised);
}
</style>

View File

@ -1,152 +0,0 @@
<script setup lang="ts">
import { Checkbox } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings.ts'
import { edit } from '@/helpers/profile'
import type { InstanceSettingsTabProps, AppSettings, Hooks } from '../../../helpers/types'
const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>()
const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideHooks = ref(
!!props.instance.hooks.pre_launch ||
!!props.instance.hooks.wrapper ||
!!props.instance.hooks.post_exit,
)
const hooks = ref(props.instance.hooks ?? globalSettings.hooks)
const editProfileObject = computed(() => {
const editProfile: {
hooks?: Hooks
} = {}
// When hooks are not overridden per-instance, we want to clear them
editProfile.hooks = overrideHooks.value ? hooks.value : {}
return editProfile
})
watch(
[overrideHooks, hooks],
async () => {
await edit(props.instance.path, editProfileObject.value)
},
{ deep: true },
)
const messages = defineMessages({
hooks: {
id: 'instance.settings.tabs.hooks.title',
defaultMessage: 'Game launch hooks',
},
hooksDescription: {
id: 'instance.settings.tabs.hooks.description',
defaultMessage:
'Hooks allow advanced users to run certain system commands before and after launching the game.',
},
customHooks: {
id: 'instance.settings.tabs.hooks.custom-hooks',
defaultMessage: 'Custom launch hooks',
},
preLaunch: {
id: 'instance.settings.tabs.hooks.pre-launch',
defaultMessage: 'Pre-launch',
},
preLaunchDescription: {
id: 'instance.settings.tabs.hooks.pre-launch.description',
defaultMessage: 'Ran before the instance is launched.',
},
preLaunchEnter: {
id: 'instance.settings.tabs.hooks.pre-launch.enter',
defaultMessage: 'Enter pre-launch command...',
},
wrapper: {
id: 'instance.settings.tabs.hooks.wrapper',
defaultMessage: 'Wrapper',
},
wrapperDescription: {
id: 'instance.settings.tabs.hooks.wrapper.description',
defaultMessage: 'Wrapper command for launching Minecraft.',
},
wrapperEnter: {
id: 'instance.settings.tabs.hooks.wrapper.enter',
defaultMessage: 'Enter wrapper command...',
},
postExit: {
id: 'instance.settings.tabs.hooks.post-exit',
defaultMessage: 'Post-exit',
},
postExitDescription: {
id: 'instance.settings.tabs.hooks.post-exit.description',
defaultMessage: 'Ran after the game closes.',
},
postExitEnter: {
id: 'instance.settings.tabs.hooks.post-exit.enter',
defaultMessage: 'Enter post-exit command...',
},
})
</script>
<template>
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.hooks) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.hooksDescription) }}
</p>
<Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="mt-2" />
<h2 class="mt-2 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.preLaunch) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.preLaunchDescription) }}
</p>
<input
id="pre-launch"
v-model="hooks.pre_launch"
autocomplete="off"
:disabled="!overrideHooks"
type="text"
:placeholder="formatMessage(messages.preLaunchEnter)"
class="w-full mt-2"
/>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.wrapper) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.wrapperDescription) }}
</p>
<input
id="wrapper"
v-model="hooks.wrapper"
autocomplete="off"
:disabled="!overrideHooks"
type="text"
:placeholder="formatMessage(messages.wrapperEnter)"
class="w-full mt-2"
/>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.postExit) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.postExitDescription) }}
</p>
<input
id="post-exit"
v-model="hooks.post_exit"
autocomplete="off"
:disabled="!overrideHooks"
type="text"
:placeholder="formatMessage(messages.postExitEnter)"
class="w-full mt-2"
/>
</div>
</template>

View File

@ -1,789 +0,0 @@
<script setup lang="ts">
import {
TransferIcon,
IssuesIcon,
HammerIcon,
DownloadIcon,
WrenchIcon,
UndoIcon,
SpinnerIcon,
UnplugIcon,
UnlinkIcon,
} from '@modrinth/assets'
import { Avatar, Checkbox, Chips, ButtonStyled, TeleportDropdownMenu } from '@modrinth/ui'
import { computed, type ComputedRef, type Ref, ref, shallowRef, watch } from 'vue'
import { edit, install, update_repair_modrinth } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { trackEvent } from '@/helpers/analytics'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import {
formatCategory,
type GameVersionTag,
type PlatformTag,
type Project,
type Version,
} from '@modrinth/utils'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { get_project, get_version_many } from '@/helpers/cache'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import dayjs from 'dayjs'
import type {
InstanceSettingsTabProps,
ManifestLoaderVersion,
Manifest,
} from '../../../helpers/types'
const { formatMessage } = useVIntl()
const repairConfirmModal = ref()
const modpackVersionModal = ref()
const modalConfirmUnpair = ref()
const modalConfirmReinstall = ref()
const props = defineProps<InstanceSettingsTabProps>()
const loader = ref(props.instance.loader)
const gameVersion = ref(props.instance.game_version)
const showSnapshots = ref(false)
const [
fabric_versions,
forge_versions,
quilt_versions,
neoforge_versions,
all_game_versions,
loaders,
] = await Promise.all([
get_loader_versions('fabric')
.then((manifest: Manifest) => shallowRef(manifest))
.catch(handleError),
get_loader_versions('forge')
.then((manifest: Manifest) => shallowRef(manifest))
.catch(handleError),
get_loader_versions('quilt')
.then((manifest: Manifest) => shallowRef(manifest))
.catch(handleError),
get_loader_versions('neo')
.then((manifest: Manifest) => shallowRef(manifest))
.catch(handleError),
get_game_versions()
.then((gameVersions: GameVersionTag[]) => shallowRef(gameVersions))
.catch(handleError),
get_loaders()
.then((value: PlatformTag[]) =>
value
.filter(
(item) => item.supported_project_types.includes('modpack') || item.name === 'vanilla',
)
.sort((a, b) => (a.name === 'vanilla' ? -1 : b.name === 'vanilla' ? 1 : 0)),
)
.then((loader: PlatformTag[]) => ref(loader))
.catch(handleError),
])
const modpackProject: Ref<Project | null> = ref(null)
const modpackVersion: Ref<Version | null> = ref(null)
const modpackVersions: Ref<Version[] | null> = ref(null)
const fetching = ref(true)
if (props.instance.linked_data && props.instance.linked_data.project_id && !props.offline) {
get_project(props.instance.linked_data.project_id, 'must_revalidate')
.then((project) => {
modpackProject.value = project
if (project && project.versions) {
get_version_many(project.versions, 'must_revalidate')
.then((versions: Version[]) => {
modpackVersions.value = versions.sort((a, b) =>
dayjs(b.date_published).diff(dayjs(a.date_published)),
)
modpackVersion.value =
versions.find(
(version: Version) => version.id === props.instance.linked_data?.version_id,
) ?? null
})
.catch(handleError)
.finally(() => {
fetching.value = false
})
}
})
.catch((err) => {
handleError(err)
fetching.value = false
})
} else {
fetching.value = false
}
const currentLoaderIcon = computed(
() => loaders?.value.find((x) => x.name === props.instance.loader)?.icon,
)
const gameVersionsForLoader = computed(() => {
return all_game_versions?.value.filter((item) => {
if (loader.value === 'fabric') {
return !!fabric_versions?.value.gameVersions.some((x) => item.version === x.id)
} else if (loader.value === 'forge') {
return !!forge_versions?.value.gameVersions.some((x) => item.version === x.id)
} else if (loader.value === 'quilt') {
return !!quilt_versions?.value.gameVersions.some((x) => item.version === x.id)
} else if (loader.value === 'neoforge') {
return !!neoforge_versions?.value.gameVersions.some((x) => item.version === x.id)
}
return []
})
})
const hasSnapshots = computed(() =>
gameVersionsForLoader.value?.some((x) => x.version_type !== 'release'),
)
const selectableGameVersionNumbers = computed(() => {
return gameVersionsForLoader.value
?.filter((x) => x.version_type === 'release' || showSnapshots.value)
.map((x) => x.version)
})
const selectableLoaderVersions: ComputedRef<ManifestLoaderVersion[] | undefined> = computed(() => {
if (gameVersion.value) {
if (loader.value === 'fabric') {
return fabric_versions?.value.gameVersions[0].loaders
} else if (loader.value === 'forge') {
return forge_versions?.value?.gameVersions?.find((item) => item.id === gameVersion.value)
?.loaders
} else if (loader.value === 'quilt') {
return quilt_versions?.value.gameVersions[0].loaders
} else if (loader.value === 'neoforge') {
return neoforge_versions?.value?.gameVersions?.find((item) => item.id === gameVersion.value)
?.loaders
}
}
return []
})
const loaderVersionIndex: Ref<number> = ref(-1)
resetLoaderVersionIndex()
function resetLoaderVersionIndex() {
loaderVersionIndex.value =
selectableLoaderVersions.value?.findIndex((x) => x.id === props.instance.loader_version) ?? -1
}
const isValid = computed(() => {
return (
selectableGameVersionNumbers.value?.includes(gameVersion.value) &&
((loaderVersionIndex.value !== undefined && loaderVersionIndex.value >= 0) ||
loader.value === 'vanilla')
)
})
const isChanged = computed(() => {
return (
loader.value !== props.instance.loader ||
gameVersion.value !== props.instance.game_version ||
(loader.value !== 'vanilla' &&
loaderVersionIndex.value !== undefined &&
loaderVersionIndex.value >= 0 &&
selectableLoaderVersions.value?.[loaderVersionIndex.value].id !==
props.instance.loader_version)
)
})
watch(loader, () => {
loaderVersionIndex.value = 0
})
const editing = ref(false)
async function saveGvLoaderEdits() {
editing.value = true
const editProfile: { loader?: string; game_version?: string; loader_version?: string } = {}
editProfile.loader = loader.value
editProfile.game_version = gameVersion.value
if (loader.value !== 'vanilla' && loaderVersionIndex.value !== undefined) {
editProfile.loader_version = selectableLoaderVersions.value?.[loaderVersionIndex.value].id
} else {
loaderVersionIndex.value = -1
}
console.log('Editing:')
console.log(loader.value)
await edit(props.instance.path, editProfile).catch(handleError)
await repairProfile(false)
editing.value = false
}
const installing = computed(() => props.instance.install_stage !== 'installed')
const repairing = ref(false)
const reinstalling = ref(false)
const changingVersion = ref(false)
async function repairProfile(force: boolean) {
if (force) {
repairing.value = true
}
await install(props.instance.path, force).catch(handleError)
if (force) {
repairing.value = false
}
trackEvent('InstanceRepair', {
loader: props.instance.loader,
game_version: props.instance.game_version,
})
}
async function unpairProfile() {
await edit(props.instance.path, {
linked_data: null,
})
modpackProject.value = null
modpackVersion.value = null
modpackVersions.value = null
modalConfirmUnpair.value.hide()
}
async function repairModpack() {
reinstalling.value = true
await update_repair_modrinth(props.instance.path).catch(handleError)
reinstalling.value = false
trackEvent('InstanceRepair', {
loader: props.instance.loader,
game_version: props.instance.game_version,
})
}
const messages = defineMessages({
cannotWhileInstalling: {
id: 'instance.settings.tabs.installation.tooltip.cannot-while-installing',
defaultMessage: 'Cannot {action} while installing',
},
cannotWhileOffline: {
id: 'instance.settings.tabs.installation.tooltip.cannot-while-offline',
defaultMessage: 'Cannot {action} while offline',
},
cannotWhileRepairing: {
id: 'instance.settings.tabs.installation.tooltip.cannot-while-repairing',
defaultMessage: 'Cannot {action} while repairing',
},
currentlyInstalled: {
id: 'instance.settings.tabs.installation.currently-installed',
defaultMessage: 'Currently installed',
},
platform: {
id: 'instance.settings.tabs.installation.platform',
defaultMessage: 'Platform',
},
gameVersion: {
id: 'instance.settings.tabs.installation.game-version',
defaultMessage: 'Game version',
},
loaderVersion: {
id: 'instance.settings.tabs.installation.loader-version',
defaultMessage: '{loader} version',
},
showAllVersions: {
id: 'instance.settings.tabs.installation.show-all-versions',
defaultMessage: 'Show all versions',
},
install: {
id: 'instance.settings.tabs.installation.install',
defaultMessage: 'Install',
},
resetSelections: {
id: 'instance.settings.tabs.installation.reset-selections',
defaultMessage: 'Reset to current',
},
unknownVersion: {
id: 'instance.settings.tabs.installation.unknown-version',
defaultMessage: '(unknown version)',
},
repairConfirmTitle: {
id: 'instance.settings.tabs.installation.repair.confirm.title',
defaultMessage: 'Repair instance?',
},
repairConfirmDescription: {
id: 'instance.settings.tabs.installation.repair.confirm.description',
defaultMessage:
'Repairing reinstalls Minecraft dependencies and checks for corruption. This may resolve issues if your game is not launching due to launcher-related errors, but will not resolve issues or crashes related to installed mods.',
},
repairButton: {
id: 'instance.settings.tabs.installation.repair.button',
defaultMessage: 'Repair',
},
repairingButton: {
id: 'instance.settings.tabs.installation.repair.button.repairing',
defaultMessage: 'Repairing',
},
repairInProgress: {
id: 'instance.settings.tabs.installation.repair.in-progress',
defaultMessage: 'Repair in progress',
},
repairAction: {
id: 'instance.settings.tabs.installation.tooltip.action.repair',
defaultMessage: 'repair',
},
changeVersionCannotWhileFetching: {
id: 'instance.settings.tabs.installation.change-version.cannot-while-fetching',
defaultMessage: 'Fetching modpack versions',
},
changeVersionButton: {
id: 'instance.settings.tabs.installation.change-version.button',
defaultMessage: 'Change version',
},
changeVersionAction: {
id: 'instance.settings.tabs.installation.tooltip.action.change-version',
defaultMessage: 'change version',
},
installingButton: {
id: 'instance.settings.tabs.installation.change-version.button.installing',
defaultMessage: 'Installing',
},
installInProgress: {
id: 'instance.settings.tabs.installation.install.in-progress',
defaultMessage: 'Installation in progress',
},
installButton: {
id: 'instance.settings.tabs.installation.change-version.button.install',
defaultMessage: 'Install',
},
alreadyInstalledVanilla: {
id: 'instance.settings.tabs.installation.change-version.already-installed.vanilla',
defaultMessage: 'Vanilla {game_version} already installed',
},
alreadyInstalledModded: {
id: 'instance.settings.tabs.installation.change-version.already-installed.modded',
defaultMessage: '{platform} {version} for Minecraft {game_version} already installed',
},
installAction: {
id: 'instance.settings.tabs.installation.tooltip.action.install',
defaultMessage: 'install',
},
installingNewVersion: {
id: 'instance.settings.tabs.installation.change-version.in-progress',
defaultMessage: 'Installing new version',
},
minecraftVersion: {
id: 'instance.settings.tabs.installation.minecraft-version',
defaultMessage: 'Minecraft {version}',
},
noLoaderVersions: {
id: 'instance.settings.tabs.installation.no-loader-versions',
defaultMessage: '{loader} is not available for Minecraft {version}. Try another mod loader.',
},
noConnection: {
id: 'instance.settings.tabs.installation.no-connection',
defaultMessage: 'Cannot fetch linked modpack details. Please check your internet connection.',
},
noModpackFound: {
id: 'instance.settings.tabs.installation.no-modpack-found',
defaultMessage:
'This instance is linked to a modpack, but the modpack could not be found on Modrinth.',
},
debugInformation: {
id: 'instance.settings.tabs.installation.debug-information',
defaultMessage: 'Debug information:',
},
fetchingModpackDetails: {
id: 'instance.settings.tabs.installation.fetching-modpack-details',
defaultMessage: 'Fetching modpack details',
},
unlinkInstanceTitle: {
id: 'instance.settings.tabs.installation.unlink.title',
defaultMessage: 'Unlink from modpack',
},
unlinkInstanceDescription: {
id: 'instance.settings.tabs.installation.unlink.description',
defaultMessage: `This instance is linked to a modpack, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the modpack.`,
},
unlinkInstanceButton: {
id: 'instance.settings.tabs.installation.unlink.button',
defaultMessage: 'Unlink instance',
},
unlinkInstanceConfirmTitle: {
id: 'instance.settings.tabs.installation.unlink.confirm.title',
defaultMessage: 'Are you sure you want to unlink this instance?',
},
unlinkInstanceConfirmDescription: {
id: 'instance.settings.tabs.installation.unlink.confirm.description',
defaultMessage:
'If you proceed, you will not be able to re-link it without creating an entirely new instance. You will no longer receive modpack updates and it will become a normal.',
},
reinstallModpackConfirmTitle: {
id: 'instance.settings.tabs.installation.reinstall.confirm.title',
defaultMessage: 'Are you sure you want to reinstall this instance?',
},
reinstallModpackConfirmDescription: {
id: 'instance.settings.tabs.installation.reinstall.confirm.description',
defaultMessage: `Reinstalling will reset all installed or modified content to what is provided by the modpack, removing any mods or content you have added on top of the original installation. This may fix unexpected behavior if changes have been made to the instance, but if your worlds now depend on additional installed content, it may break existing worlds.`,
},
reinstallModpackTitle: {
id: 'instance.settings.tabs.installation.reinstall.title',
defaultMessage: 'Reinstall modpack',
},
reinstallModpackDescription: {
id: 'instance.settings.tabs.installation.reinstall.description',
defaultMessage: `Resets the instance's content to its original state, removing any mods or content you have added on top of the original modpack.`,
},
reinstallModpackButton: {
id: 'instance.settings.tabs.installation.reinstall.button',
defaultMessage: 'Reinstall modpack',
},
reinstallingModpackButton: {
id: 'instance.settings.tabs.installation.reinstall.button.reinstalling',
defaultMessage: 'Reinstalling modpack',
},
reinstallAction: {
id: 'instance.settings.tabs.installation.tooltip.action.reinstall',
defaultMessage: 'reinstall',
},
})
</script>
<template>
<ConfirmModalWrapper
ref="repairConfirmModal"
:title="formatMessage(messages.repairConfirmTitle)"
:description="formatMessage(messages.repairConfirmDescription)"
:proceed-icon="HammerIcon"
:proceed-label="formatMessage(messages.repairButton)"
:danger="false"
:show-ad-on-close="false"
@proceed="() => repairProfile(true)"
/>
<ModpackVersionModal
v-if="instance.linked_data && modpackVersions"
ref="modpackVersionModal"
:instance="instance"
:versions="modpackVersions"
@finish-install="
() => {
changingVersion = false
modpackVersion =
modpackVersions?.find(
(version: Version) => version.id === props.instance.linked_data?.version_id,
) ?? null
}
"
/>
<ConfirmModalWrapper
ref="modalConfirmUnpair"
:title="formatMessage(messages.unlinkInstanceConfirmTitle)"
:description="formatMessage(messages.unlinkInstanceConfirmDescription)"
:proceed-icon="UnlinkIcon"
:proceed-label="formatMessage(messages.unlinkInstanceButton)"
:show-ad-on-close="false"
@proceed="() => unpairProfile()"
/>
<ConfirmModalWrapper
ref="modalConfirmReinstall"
:title="formatMessage(messages.reinstallModpackConfirmTitle)"
:description="formatMessage(messages.reinstallModpackConfirmDescription)"
:proceed-icon="DownloadIcon"
:proceed-label="formatMessage(messages.reinstallModpackButton)"
:show-ad-on-close="false"
@proceed="() => repairModpack()"
/>
<div>
<h2 id="project-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.currentlyInstalled) }}
</h2>
<div
v-if="!modpackProject && instance.linked_data && offline && !fetching"
class="text-secondary font-medium mb-2"
>
<UnplugIcon class="top-[3px] relative" /> {{ formatMessage(messages.noConnection) }}
</div>
<div v-else-if="!modpackProject && instance.linked_data && !fetching" class="mb-2">
<p class="text-brand-red font-medium mt-0">
<IssuesIcon class="top-[3px] relative" /> {{ formatMessage(messages.noModpackFound) }}
</p>
<p>{{ formatMessage(messages.debugInformation) }}</p>
<div class="bg-bg p-6 rounded-2xl mt-2 text-sm text-secondary">
{{ instance.linked_data }}
</div>
</div>
<div class="flex gap-4 items-center justify-between p-4 bg-bg rounded-2xl">
<div v-if="fetching" class="flex items-center gap-2 h-10">
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.fetchingModpackDetails) }}
</div>
<template v-else>
<div class="flex gap-2 items-center">
<Avatar v-if="modpackProject" :src="modpackProject?.icon_url" size="40px" />
<div
v-else
class="w-10 h-10 flex items-center justify-center rounded-full bg-button-bg border-solid border-[1px] border-button-border p-2 [&_svg]:h-full [&_svg]:w-full"
>
<div v-if="!!currentLoaderIcon" class="contents" v-html="currentLoaderIcon" />
<WrenchIcon v-else />
</div>
<div class="flex flex-col gap-2 justify-center">
<span class="font-semibold leading-none">
{{
modpackProject
? modpackProject.title
: formatMessage(messages.minecraftVersion, { version: instance.game_version })
}}
</span>
<span class="text-sm text-secondary leading-none">
{{
modpackProject
? modpackVersion
? modpackVersion?.version_number
: 'Unknown version'
: formatCategory(instance.loader)
}}
<template v-if="instance.loader !== 'vanilla' && !modpackProject">
{{ instance.loader_version || formatMessage(messages.unknownVersion) }}
</template>
</span>
</div>
</div>
<div class="flex gap-1">
<ButtonStyled color="orange" type="transparent" hover-color-fill="background">
<button
v-tooltip="
repairing
? formatMessage(messages.repairInProgress)
: installing || reinstalling
? formatMessage(messages.cannotWhileInstalling, {
action: formatMessage(messages.repairAction),
})
: offline
? formatMessage(messages.cannotWhileOffline, {
action: formatMessage(messages.repairAction),
})
: null
"
:disabled="installing || repairing || reinstalling || offline"
@click="repairConfirmModal.show()"
>
<SpinnerIcon v-if="repairing" class="animate-spin" />
<HammerIcon v-else />
{{
repairing
? formatMessage(messages.repairingButton)
: formatMessage(messages.repairButton)
}}
</button>
</ButtonStyled>
<ButtonStyled v-if="modpackProject" hover-color-fill="background">
<button
v-tooltip="
changingVersion
? formatMessage(messages.installingNewVersion)
: repairing
? formatMessage(messages.cannotWhileRepairing, {
action: formatMessage(messages.changeVersionAction),
})
: installing || reinstalling
? formatMessage(messages.cannotWhileInstalling, {
action: formatMessage(messages.changeVersionAction),
})
: fetching && !modpackVersions
? formatMessage(messages.changeVersionCannotWhileFetching)
: offline
? formatMessage(messages.cannotWhileOffline, {
action: formatMessage(messages.changeVersionAction),
})
: null
"
:disabled="
changingVersion ||
repairing ||
installing ||
reinstalling ||
offline ||
fetching ||
!modpackVersions
"
@click="
() => {
changingVersion = true
modpackVersionModal.show()
}
"
>
<SpinnerIcon v-if="changingVersion" class="animate-spin" />
<TransferIcon v-else />
{{
changingVersion
? formatMessage(messages.installingButton)
: formatMessage(messages.changeVersionButton)
}}
</button>
</ButtonStyled>
</div>
</template>
</div>
<template v-if="!instance.linked_data || !instance.linked_data.locked">
<h2 class="m-0 mt-4 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.platform) }}
</h2>
<Chips v-if="loaders" v-model="loader" :items="loaders.map((x) => x.name)" class="mt-2" />
<h2 class="m-0 mt-4 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.gameVersion) }}
</h2>
<div class="flex flex-wrap mt-2 gap-2">
<TeleportDropdownMenu
v-if="selectableGameVersionNumbers !== undefined"
v-model="gameVersion"
:options="selectableGameVersionNumbers"
name="Game Version Dropdown"
/>
<Checkbox
v-if="hasSnapshots"
v-model="showSnapshots"
:label="formatMessage(messages.showAllVersions)"
/>
</div>
<template v-if="loader !== 'vanilla'">
<h2 class="m-0 mt-4 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.loaderVersion, { loader: formatCategory(loader) }) }}
</h2>
<TeleportDropdownMenu
v-if="selectableLoaderVersions"
:model-value="selectableLoaderVersions[loaderVersionIndex]"
:options="selectableLoaderVersions"
:display-name="(option: ManifestLoaderVersion) => option?.id"
name="Version selector"
class="mt-2"
@change="(value) => (loaderVersionIndex = value.index)"
/>
<div v-else class="mt-2 text-brand-red flex gap-2 items-center">
<IssuesIcon />
{{ formatMessage(messages.noLoaderVersions, { loader: loader, version: gameVersion }) }}
</div>
</template>
<div class="mt-4 flex flex-wrap gap-2">
<ButtonStyled color="brand">
<button
v-tooltip="
installing || reinstalling
? formatMessage(messages.installInProgress)
: !isChanged
? formatMessage(
loader === 'vanilla'
? messages.alreadyInstalledVanilla
: messages.alreadyInstalledModded,
{
platform: formatCategory(loader),
version: instance.loader_version,
game_version: gameVersion,
},
)
: repairing
? formatMessage(messages.cannotWhileRepairing, {
action: formatMessage(messages.installAction),
})
: offline
? formatMessage(messages.cannotWhileOffline, {
action: formatMessage(messages.installAction),
})
: null
"
:disabled="!isValid || !isChanged || editing || offline || repairing"
@click="saveGvLoaderEdits()"
>
<SpinnerIcon v-if="editing" class="animate-spin" />
<DownloadIcon v-else />
{{
editing
? formatMessage(messages.installingButton)
: formatMessage(messages.installButton)
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="!isChanged"
@click="
() => {
loader = instance.loader
gameVersion = instance.game_version
resetLoaderVersionIndex()
}
"
>
<UndoIcon />
{{ formatMessage(messages.resetSelections) }}
</button>
</ButtonStyled>
</div>
</template>
<template v-else>
<template v-if="instance.linked_data && instance.linked_data.locked">
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.unlinkInstanceTitle) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.unlinkInstanceDescription) }}
</p>
<ButtonStyled>
<button class="mt-2" @click="modalConfirmUnpair.show()">
<UnlinkIcon /> {{ formatMessage(messages.unlinkInstanceButton) }}
</button>
</ButtonStyled>
<template v-if="modpackProject">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast block mt-4">
{{ formatMessage(messages.reinstallModpackTitle) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.reinstallModpackDescription) }}
</p>
</div>
<ButtonStyled color="red" type="outlined">
<button
v-tooltip="
reinstalling
? formatMessage(messages.reinstallingModpackButton)
: repairing
? formatMessage(messages.cannotWhileRepairing, {
action: formatMessage(messages.reinstallAction),
})
: installing
? formatMessage(messages.cannotWhileInstalling, {
action: formatMessage(messages.reinstallAction),
})
: offline
? formatMessage(messages.cannotWhileOffline, {
action: formatMessage(messages.reinstallAction),
})
: null
"
class="mt-2"
:disabled="
changingVersion ||
repairing ||
installing ||
offline ||
fetching ||
!modpackVersions
"
@click="modalConfirmReinstall.show()"
>
<SpinnerIcon v-if="reinstalling" class="animate-spin" />
<DownloadIcon v-else />
{{
reinstalling
? formatMessage(messages.reinstallingModpackButton)
: formatMessage(messages.reinstallModpackButton)
}}
</button>
</ButtonStyled>
</template>
</template>
</template>
</div>
</template>

View File

@ -1,190 +0,0 @@
<script setup lang="ts">
import { Checkbox, Slider } from '@modrinth/ui'
import { CheckCircleIcon, XCircleIcon } from '@modrinth/assets'
import { computed, readonly, ref, watch } from 'vue'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get } from '@/helpers/settings.ts'
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
import useMemorySlider from '@/composables/useMemorySlider'
const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>()
const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideJavaInstall = ref(!!props.instance.java_path)
const optimalJava = readonly(await get_optimal_jre_key(props.instance.path).catch(handleError))
const javaInstall = ref({ path: optimalJava.path ?? props.instance.java_path })
const overrideJavaArgs = ref(props.instance.extra_launch_args?.length !== undefined)
const javaArgs = ref(
(props.instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' '),
)
const overrideEnvVars = ref(props.instance.custom_env_vars?.length !== undefined)
const envVars = ref(
(props.instance.custom_env_vars ?? globalSettings.custom_env_vars)
.map((x) => x.join('='))
.join(' '),
)
const overrideMemorySettings = ref(!!props.instance.memory)
const memory = ref(props.instance.memory ?? globalSettings.memory)
const { maxMemory, snapPoints } = await useMemorySlider()
const editProfileObject = computed(() => {
const editProfile: {
java_path?: string
extra_launch_args?: string[]
custom_env_vars?: string[][]
memory?: MemorySettings
} = {}
if (overrideJavaInstall.value) {
if (javaInstall.value.path !== '') {
editProfile.java_path = javaInstall.value.path.replace('java.exe', 'javaw.exe')
}
}
if (overrideJavaArgs.value) {
editProfile.extra_launch_args = javaArgs.value.trim().split(/\s+/).filter(Boolean)
}
if (overrideEnvVars.value) {
editProfile.custom_env_vars = envVars.value
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
}
if (overrideMemorySettings.value) {
editProfile.memory = memory.value
}
return editProfile
})
watch(
[
overrideJavaInstall,
javaInstall,
overrideJavaArgs,
javaArgs,
overrideEnvVars,
envVars,
overrideMemorySettings,
memory,
],
async () => {
await edit(props.instance.path, editProfileObject.value)
},
{ deep: true },
)
const messages = defineMessages({
javaInstallation: {
id: 'instance.settings.tabs.java.java-installation',
defaultMessage: 'Java installation',
},
javaArguments: {
id: 'instance.settings.tabs.java.java-arguments',
defaultMessage: 'Java arguments',
},
javaEnvironmentVariables: {
id: 'instance.settings.tabs.java.environment-variables',
defaultMessage: 'Environment variables',
},
javaMemory: {
id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated',
},
hooks: {
id: 'instance.settings.tabs.java.hooks',
defaultMessage: 'Hooks',
},
})
</script>
<template>
<div>
<h2 id="project-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaInstallation) }}
</h2>
<Checkbox v-model="overrideJavaInstall" label="Custom Java installation" class="mb-2" />
<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
>
</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
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-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaMemory) }}
</h2>
<Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2" />
<Slider
id="max-memory"
v-model="memory.maximum"
:disabled="!overrideMemorySettings"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaArguments) }}
</h2>
<Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" />
<input
id="java-args"
v-model="javaArgs"
autocomplete="off"
:disabled="!overrideJavaArgs"
type="text"
class="w-full"
placeholder="Enter java arguments..."
/>
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaEnvironmentVariables) }}
</h2>
<Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2" />
<input
id="env-vars"
v-model="envVars"
autocomplete="off"
:disabled="!overrideEnvVars"
type="text"
class="w-full"
placeholder="Enter environmental variables..."
/>
</div>
</template>

View File

@ -1,164 +0,0 @@
<script setup lang="ts">
import { Checkbox, Toggle } from '@modrinth/ui'
import { computed, ref, type Ref, watch } from 'vue'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings.ts'
import { edit } from '@/helpers/profile'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>()
const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideWindowSettings = ref(
!!props.instance.game_resolution || !!props.instance.force_fullscreen,
)
const resolution: Ref<[number, number]> = ref(
props.instance.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]),
)
const fullscreenSetting: Ref<boolean> = ref(
props.instance.force_fullscreen ?? globalSettings.force_fullscreen,
)
const editProfileObject = computed(() => {
const editProfile: {
force_fullscreen?: boolean
game_resolution?: [number, number]
} = {}
if (overrideWindowSettings.value) {
editProfile.force_fullscreen = fullscreenSetting.value
if (!fullscreenSetting.value) {
editProfile.game_resolution = resolution.value
}
}
return editProfile
})
watch(
[overrideWindowSettings, resolution, fullscreenSetting],
async () => {
await edit(props.instance.path, editProfileObject.value)
},
{ deep: true },
)
const messages = defineMessages({
customWindowSettings: {
id: 'instance.settings.tabs.window.custom-window-settings',
defaultMessage: 'Custom window settings',
},
fullscreen: {
id: 'instance.settings.tabs.window.fullscreen',
defaultMessage: 'Fullscreen',
},
fullscreenDescription: {
id: 'instance.settings.tabs.window.fullscreen.description',
defaultMessage: 'Make the game start in full screen when launched (using options.txt).',
},
width: {
id: 'instance.settings.tabs.window.width',
defaultMessage: 'Width',
},
widthDescription: {
id: 'instance.settings.tabs.window.width.description',
defaultMessage: 'The width of the game window when launched.',
},
enterWidth: {
id: 'instance.settings.tabs.window.width.enter',
defaultMessage: 'Enter width...',
},
height: {
id: 'instance.settings.tabs.window.height',
defaultMessage: 'Height',
},
heightDescription: {
id: 'instance.settings.tabs.window.height.description',
defaultMessage: 'The height of the game window when launched.',
},
enterHeight: {
id: 'instance.settings.tabs.window.height.enter',
defaultMessage: 'Enter height...',
},
})
</script>
<template>
<div>
<Checkbox
v-model="overrideWindowSettings"
:label="formatMessage(messages.customWindowSettings)"
@update:model-value="
(value) => {
if (!value) {
resolution = globalSettings.game_resolution
fullscreenSetting = globalSettings.force_fullscreen
}
}
"
/>
<div class="mt-2 flex items-center gap-4 justify-between">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.fullscreen) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.fullscreenDescription) }}
</p>
</div>
<Toggle
id="fullscreen"
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
:disabled="!overrideWindowSettings"
@update:model-value="
(e) => {
fullscreenSetting = e
}
"
/>
</div>
<div class="mt-4 flex items-center gap-4 justify-between">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.width) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.widthDescription) }}
</p>
</div>
<input
id="width"
v-model="resolution[0]"
autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting"
type="number"
:placeholder="formatMessage(messages.enterWidth)"
/>
</div>
<div class="mt-4 flex items-center gap-4 justify-between">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.height) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.heightDescription) }}
</p>
</div>
<input
id="height"
v-model="resolution[1]"
autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting"
type="number"
:placeholder="formatMessage(messages.enterHeight)"
/>
</div>
</div>
</template>

View File

@ -1,161 +0,0 @@
<script setup lang="ts">
import {
ReportIcon,
ModrinthIcon,
ShieldIcon,
SettingsIcon,
GaugeIcon,
PaintbrushIcon,
GameIcon,
CoffeeIcon,
} from '@modrinth/assets'
import { TabbedModal } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { useVIntl, defineMessage } from '@vintl/vintl'
import AppearanceSettings from '@/components/ui/settings/AppearanceSettings.vue'
import JavaSettings from '@/components/ui/settings/JavaSettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/ResourceManagementSettings.vue'
import PrivacySettings from '@/components/ui/settings/PrivacySettings.vue'
import DefaultInstanceSettings from '@/components/ui/settings/DefaultInstanceSettings.vue'
import { getVersion } from '@tauri-apps/api/app'
import { version as getOsVersion, platform as getOsPlatform } from '@tauri-apps/plugin-os'
import { useTheming } from '@/store/state'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get, set } from '@/helpers/settings.ts'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const devModeCounter = ref(0)
const developerModeEnabled = defineMessage({
id: 'app.settings.developer-mode-enabled',
defaultMessage: 'Developer mode enabled.',
})
const tabs = [
{
name: defineMessage({
id: 'app.settings.tabs.appearance',
defaultMessage: 'Appearance',
}),
icon: PaintbrushIcon,
content: AppearanceSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.privacy',
defaultMessage: 'Privacy',
}),
icon: ShieldIcon,
content: PrivacySettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.java-installations',
defaultMessage: 'Java installations',
}),
icon: CoffeeIcon,
content: JavaSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.default-instance-options',
defaultMessage: 'Default instance options',
}),
icon: GameIcon,
content: DefaultInstanceSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.resource-management',
defaultMessage: 'Resource management',
}),
icon: GaugeIcon,
content: ResourceManagementSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.feature-flags',
defaultMessage: 'Feature flags',
}),
icon: ReportIcon,
content: FeatureFlagSettings,
developerOnly: true,
},
]
const modal = ref()
function show() {
modal.value.show()
}
const isOpen = computed(() => modal.value?.isOpen)
defineExpose({ show, isOpen })
const version = await getVersion()
const osPlatform = getOsPlatform()
const osVersion = getOsVersion()
const settings = ref(await get())
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
function devModeCount() {
devModeCounter.value++
if (devModeCounter.value > 5) {
themeStore.devMode = !themeStore.devMode
settings.value.developer_mode = !!themeStore.devMode
devModeCounter.value = 0
if (!themeStore.devMode && tabs[modal.value.selectedTab].developerOnly) {
modal.value.setTab(0)
}
}
}
</script>
<template>
<ModalWrapper ref="modal">
<template #title>
<span class="flex items-center gap-2 text-lg font-extrabold text-contrast">
<SettingsIcon /> Settings
</span>
</template>
<TabbedModal :tabs="tabs.filter((t) => !t.developerOnly || themeStore.devMode)">
<template #footer>
<div class="mt-auto text-secondary text-sm">
<p v-if="themeStore.devMode" class="text-brand font-semibold m-0 mb-2">
{{ formatMessage(developerModeEnabled) }}
</p>
<div class="flex items-center gap-3">
<button
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
:class="{ 'text-brand': themeStore.devMode, 'text-secondary': !themeStore.devMode }"
@click="devModeCount"
>
<ModrinthIcon class="w-6 h-6" />
</button>
<div>
<p class="m-0">Modrinth App {{ version }}</p>
<p class="m-0">
<span v-if="osPlatform === 'macos'">MacOS</span>
<span v-else class="capitalize">{{ osPlatform }}</span>
{{ osVersion }}
</p>
</div>
</div>
</div>
</template>
</TabbedModal>
</ModalWrapper>
</template>

View File

@ -1,42 +0,0 @@
<script setup lang="ts">
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
defineProps({
onFlowCancel: {
type: Function,
default() {
return async () => {}
},
},
})
const modal = ref()
function show() {
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show, hide })
</script>
<template>
<ModalWrapper ref="modal" @hide="onFlowCancel">
<template #title>
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
<LogInIcon /> Sign in
</span>
</template>
<div class="flex justify-center gap-2">
<SpinnerIcon class="w-12 h-12 animate-spin" />
</div>
<p class="text-sm text-secondary">
Please sign in at the browser window that just opened to continue.
</p>
</ModalWrapper>
</template>

View File

@ -2,11 +2,11 @@
import { ref } from 'vue'
import { ConfirmModal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
import { useTheming } from '@/store/theme.js'
const themeStore = useTheming()
const props = defineProps({
defineProps({
confirmationText: {
type: String,
default: '',
@ -25,26 +25,10 @@ const props = defineProps({
default: 'No description defined',
required: true,
},
proceedIcon: {
type: Object,
default: undefined,
},
proceedLabel: {
type: String,
default: 'Proceed',
},
danger: {
type: Boolean,
default: true,
},
showAdOnClose: {
type: Boolean,
default: true,
},
markdown: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['proceed'])
@ -62,9 +46,7 @@ defineExpose({
})
function onModalHide() {
if (props.showAdOnClose) {
show_ads_window()
}
show_ads_window()
}
function proceed() {
@ -79,12 +61,9 @@ function proceed() {
:has-to-type="hasToType"
:title="title"
:description="description"
:proceed-icon="proceedIcon"
:proceed-label="proceedLabel"
:on-hide="onModalHide"
:noblur="!themeStore.advancedRendering"
:danger="danger"
:markdown="markdown"
@proceed="proceed"
/>
</template>

View File

@ -1,20 +0,0 @@
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { Avatar } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { GameInstance } from '@/helpers/types'
defineProps<{
instance: GameInstance
}>()
</script>
<template>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
size="24px"
:tint-by="instance.path"
/>
{{ instance.name }} <ChevronRightIcon />
</span>
</template>

View File

@ -1,98 +0,0 @@
<script setup lang="ts">
import {
ChevronRightIcon,
CoffeeIcon,
InfoIcon,
WrenchIcon,
MonitorIcon,
CodeIcon,
} from '@modrinth/assets'
import { Avatar, TabbedModal, type TabbedModalTab } from '@modrinth/ui'
import { ref } from 'vue'
import { defineMessage, useVIntl } from '@vintl/vintl'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import { convertFileSrc } from '@tauri-apps/api/core'
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import type { InstanceSettingsTabProps } from '../../../helpers/types'
const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>()
const tabs: TabbedModalTab<InstanceSettingsTabProps>[] = [
{
name: defineMessage({
id: 'instance.settings.tabs.general',
defaultMessage: 'General',
}),
icon: InfoIcon,
content: GeneralSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.installation',
defaultMessage: 'Installation',
}),
icon: WrenchIcon,
content: InstallationSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.window',
defaultMessage: 'Window',
}),
icon: MonitorIcon,
content: WindowSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.java',
defaultMessage: 'Java and memory',
}),
icon: CoffeeIcon,
content: JavaSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.hooks',
defaultMessage: 'Launch hooks',
}),
icon: CodeIcon,
content: HooksSettings,
},
]
const modal = ref()
function show() {
modal.value.show()
}
defineExpose({ show })
const titleMessage = defineMessage({
id: 'instance.settings.title',
defaultMessage: 'Settings',
})
</script>
<template>
<ModalWrapper ref="modal">
<template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
size="24px"
:tint-by="props.instance.path"
/>
{{ instance.name }} <ChevronRightIcon />
<span class="font-extrabold text-contrast">{{ formatMessage(titleMessage) }}</span>
</span>
</template>
<TabbedModal :tabs="tabs.map((tab) => ({ ...tab, props }))" />
</ModalWrapper>
</template>

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { NewModal as Modal } from '@modrinth/ui'
import { ref } from 'vue'
import { Modal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
import { useTheming } from '@/store/theme.js'
const themeStore = useTheming()
@ -21,37 +21,29 @@ const props = defineProps({
return () => {}
},
},
showAdOnClose: {
type: Boolean,
default: true,
},
})
const modal = useTemplateRef('modal')
const modal = ref(null)
defineExpose({
show: (e: MouseEvent) => {
show: () => {
hide_ads_window()
modal.value?.show(e)
modal.value.show()
},
hide: () => {
onModalHide()
modal.value?.hide()
modal.value.hide()
},
})
function onModalHide() {
if (props.showAdOnClose) {
show_ads_window()
}
props.onHide?.()
show_ads_window()
props.onHide()
}
</script>
<template>
<Modal ref="modal" :header="header" :noblur="!themeStore.advancedRendering" @hide="onModalHide">
<template #title>
<slot name="title" />
</template>
<slot />
</Modal>
</template>

View File

@ -2,7 +2,7 @@
import { ref } from 'vue'
import { ShareModal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
import { useTheming } from '@/store/theme.js'
const themeStore = useTheming()

View File

@ -1,130 +0,0 @@
<script setup lang="ts">
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings.ts'
import { ref, watch } from 'vue'
import { getOS } from '@/helpers/utils'
import type { ColorTheme } from '@/store/theme.ts'
const themeStore = useTheming()
const os = ref(await getOS())
const settings = ref(await get())
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
</script>
<template>
<h2 class="m-0 text-lg font-extrabold text-contrast">Color theme</h2>
<p class="m-0 mt-1">Select your preferred color theme for Modrinth App.</p>
<ThemeSelector
:update-color-theme="
(theme: ColorTheme) => {
themeStore.setThemeState(theme)
settings.theme = theme
}
"
:current-theme="settings.theme"
:theme-options="themeStore.getThemeOptions()"
system-theme-color="system"
/>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Advanced rendering</h2>
<p class="m-0 mt-1">
Enables advanced rendering such as blur effects that may cause performance issues without
hardware-accelerated rendering.
</p>
</div>
<Toggle
id="advanced-rendering"
:model-value="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = e
settings.advanced_rendering = themeStore.advancedRendering
}
"
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2>
<p class="m-0 mt-1">Disables the nametag above your player on the skins page.</p>
</div>
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
</div>
<div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Native decorations</h2>
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
</div>
<Toggle id="native-decorations" v-model="settings.native_decorations" />
</div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2>
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
</div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
</div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Default landing page</h2>
<p class="m-0 mt-1">Change the page to which the launcher opens on.</p>
</div>
<TeleportDropdownMenu
id="opening-page"
v-model="settings.default_page"
name="Opening page dropdown"
class="w-40"
:options="['Home', 'Library']"
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Jump back into worlds</h2>
<p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag('worlds_in_home')"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag('worlds_in_home')
themeStore.featureFlags['worlds_in_home'] = newValue
settings.feature_flags['worlds_in_home'] = newValue
}
"
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2>
<p class="m-0 mt-1">Enables the ability to toggle the sidebar.</p>
</div>
<Toggle
id="toggle-sidebar"
:model-value="settings.toggle_sidebar"
@update:model-value="
(e) => {
settings.toggle_sidebar = e
themeStore.toggleSidebar = settings.toggle_sidebar
}
"
/>
</div>
</template>

View File

@ -1,173 +0,0 @@
<script setup lang="ts">
import { get, set } from '@/helpers/settings.ts'
import { ref, watch } from 'vue'
import { Slider, Toggle } from '@modrinth/ui'
import useMemorySlider from '@/composables/useMemorySlider'
const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).join(' ')
const settings = ref(fetchSettings)
const { maxMemory, snapPoints } = await useMemorySlider()
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean)
setSettings.custom_env_vars = setSettings.envVars
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
if (!setSettings.hooks.pre_launch) {
setSettings.hooks.pre_launch = null
}
if (!setSettings.hooks.wrapper) {
setSettings.hooks.wrapper = null
}
if (!setSettings.hooks.post_exit) {
setSettings.hooks.post_exit = null
}
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
</script>
<template>
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Window size</h2>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Fullscreen</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
Overwrites the options.txt file to start in full screen when launched.
</p>
</div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Width</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The width of the game window when launched.
</p>
</div>
<input
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter width..."
/>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Height</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The height of the game window when launched.
</p>
</div>
<input
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
class="input"
placeholder="Enter height..."
/>
</div>
<hr class="mt-4 bg-button-border border-none h-[1px]" />
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Memory allocated</h2>
<p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Java arguments</h2>
<input
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
placeholder="Enter java arguments..."
class="w-full"
/>
<h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Environmental variables</h2>
<input
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
placeholder="Enter environmental variables..."
class="w-full"
/>
<hr class="mt-4 bg-button-border border-none h-[1px]" />
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Hooks</h2>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Pre launch</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran before the instance is launched.</p>
<input
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
placeholder="Enter pre-launch command..."
class="w-full"
/>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Wrapper</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
Wrapper command for launching Minecraft.
</p>
<input
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
placeholder="Enter wrapper command..."
class="w-full"
/>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Post exit</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran after the game closes.</p>
<input
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
placeholder="Enter post-exit command..."
class="w-full"
/>
</div>
</template>

View File

@ -1,40 +0,0 @@
<script setup lang="ts">
import { Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { ref, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming()
const settings = ref(await getSettings())
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
function setFeatureFlag(key: string, value: boolean) {
themeStore.featureFlags[key] = value
settings.value.feature_flags[key] = value
}
watch(
settings,
async () => {
await setSettings(settings.value)
},
{ deep: true },
)
</script>
<template>
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
{{ option.replaceAll('_', ' ') }}
</h2>
</div>
<Toggle
id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
/>
</div>
</template>

View File

@ -1,32 +0,0 @@
<script setup>
import { ref } from 'vue'
import { get_java_versions, set_java_version } from '@/helpers/jre'
import { handleError } from '@/store/notifications'
import JavaSelector from '@/components/ui/JavaSelector.vue'
const javaVersions = ref(await get_java_versions().catch(handleError))
async function updateJavaVersion(version) {
if (version?.path === '') {
version.path = undefined
}
if (version?.path) {
version.path = version.path.replace('java.exe', 'javaw.exe')
}
await set_java_version(version).catch(handleError)
}
</script>
<template>
<div v-for="(javaVersion, index) in [21, 17, 8]" :key="`java-${javaVersion}`">
<h2 class="m-0 text-lg font-extrabold text-contrast" :class="{ 'mt-4': index !== 0 }">
Java {{ javaVersion }} location
</h2>
<JavaSelector
:id="'java-selector-' + javaVersion"
v-model="javaVersions[javaVersion]"
:version="javaVersion"
@update:model-value="updateJavaVersion"
/>
</div>
</template>

View File

@ -1,62 +0,0 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { Toggle } from '@modrinth/ui'
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'
const settings = ref(await get())
watch(
settings,
async () => {
if (settings.value.telemetry) {
optInAnalytics()
} else {
optOutAnalytics()
}
await set(settings.value)
},
{ deep: true },
)
</script>
<template>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Personalized ads</h2>
<p class="m-0 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-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Telemetry</h2>
<p class="m-0 text-sm">
Modrinth collects anonymized analytics and usage data to improve our user experience and
customize your experience. By disabling this option, you opt out and your data will no
longer be collected.
</p>
</div>
<Toggle id="opt-out-analytics" v-model="settings.telemetry" />
</div>
<div class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Discord RPC</h2>
<p class="m-0 text-sm">
Manages the Discord Rich Presence integration. Disabling this will cause 'Modrinth' to no
longer show up as a game or app you are using on your Discord profile.
</p>
<p class="m-0 mt-2 text-sm">
Note: This will not prevent any instance-specific Discord Rich Presence integrations, such
as those added by mods. (app restart required to take effect)
</p>
</div>
<Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" />
</div>
</template>

View File

@ -1,117 +0,0 @@
<script setup>
import { Button, Slider } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { purge_cache_types } from '@/helpers/cache.js'
import { handleError } from '@/store/notifications.js'
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { open } from '@tauri-apps/plugin-dialog'
const settings = ref(await get())
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
async function purgeCache() {
await purge_cache_types([
'project',
'version',
'user',
'team',
'organization',
'loader_manifest',
'minecraft_manifest',
'categories',
'report_types',
'loaders',
'game_versions',
'donation_platforms',
'file_update',
'search_results',
]).catch(handleError)
}
async function findLauncherDir() {
const newDir = await open({
multiple: false,
directory: true,
title: 'Select a new app directory',
})
if (newDir) {
settings.value.custom_dir = newDir
}
}
</script>
<template>
<h2 class="m-0 text-lg font-extrabold text-contrast">App directory</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The directory where the launcher stores all of its files. Changes will be applied after
restarting the launcher.
</p>
<div class="m-1 my-2">
<div class="iconified-input w-full">
<BoxIcon />
<input id="appDir" v-model="settings.custom_dir" type="text" class="input" />
<Button class="r-btn" @click="findLauncherDir">
<FolderSearchIcon />
</Button>
</div>
</div>
<div>
<ConfirmModalWrapper
ref="purgeCacheConfirmModal"
title="Are you sure you want to purge the cache?"
description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
:has-to-type="false"
proceed-label="Purge cache"
:show-ad-on-close="false"
@proceed="purgeCache"
/>
<h2 class="m-0 text-lg font-extrabold text-contrast">App cache</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The Modrinth app stores a cache of data to speed up loading. This can be purged to force the
app to reload data. This may slow down the app temporarily.
</p>
</div>
<button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()">
<TrashIcon />
Purge cache
</button>
<h2 class="m-0 text-lg font-extrabold text-contrast mt-4">Maximum concurrent downloads</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The maximum amount of files the launcher can download at the same time. Set this to a lower
value if you have a poor internet connection. (app restart required to take effect)
</p>
<Slider
id="max-downloads"
v-model="settings.max_concurrent_downloads"
:min="1"
:max="10"
:step="1"
/>
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Maximum concurrent writes</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The maximum amount of files the launcher can write to the disk at once. Set this to a lower
value if you are frequently getting I/O errors. (app restart required to take effect)
</p>
<Slider id="max-writes" v-model="settings.max_concurrent_writes" :min="1" :max="50" :step="1" />
</template>

View File

@ -1,413 +0,0 @@
<template>
<UploadSkinModal ref="uploadModal" />
<ModalWrapper ref="modal" @on-hide="resetState">
<template #title>
<span class="text-lg font-extrabold text-contrast">
{{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }}
</span>
</template>
<div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer
:variant="variant"
:texture-src="previewSkin || ''"
:cape-src="selectedCapeTexture"
:scale="1.4"
:fov="50"
:initial-rotation="Math.PI / 8"
class="h-full w-full"
/>
</div>
</div>
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
<section>
<h2 class="text-base font-semibold mb-2">Texture</h2>
<Button @click="openUploadSkinModal"> <UploadIcon /> Replace texture </Button>
</section>
<section>
<h2 class="text-base font-semibold mb-2">Arm style</h2>
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
<template #default="{ item }">
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
</template>
</RadioButtons>
</section>
<section>
<h2 class="text-base font-semibold mb-2">Cape</h2>
<div class="flex gap-2">
<CapeButton
v-if="defaultCape"
:id="defaultCape.id"
:texture="defaultCape.texture"
:name="undefined"
:selected="!selectedCape"
faded
@select="selectCape(undefined)"
>
<span>Use default cape</span>
</CapeButton>
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
<span>Use default cape</span>
</CapeLikeTextButton>
<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"
>
<template #icon><ChevronRightIcon /></template>
<span>More</span>
</CapeLikeTextButton>
</div>
</section>
</div>
</div>
<div class="flex gap-2 mt-12">
<ButtonStyled color="brand" :disabled="disableSave || isSaving">
<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>
<Button :disabled="isSaving" @click="hide"><XIcon />Cancel</Button>
</div>
</ModalWrapper>
<SelectCapeModal
ref="selectCapeModal"
:capes="capes || []"
@select="handleCapeSelected"
@cancel="handleCapeCancel"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch, useTemplateRef } from 'vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import {
SkinPreviewRenderer,
Button,
RadioButtons,
CapeButton,
CapeLikeTextButton,
ButtonStyled,
} from '@modrinth/ui'
import {
add_and_equip_custom_skin,
remove_custom_skin,
unequip_skin,
type Skin,
type Cape,
type SkinModel,
get_normalized_skin_texture,
determineModelType,
} from '@/helpers/skins.ts'
import { handleError } from '@/store/notifications'
import {
UploadIcon,
CheckIcon,
SaveIcon,
XIcon,
ChevronRightIcon,
SpinnerIcon,
} from '@modrinth/assets'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
const modal = useTemplateRef('modal')
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<string | null>(null)
const previewSkin = ref<string>('')
const variant = ref<SkinModel>('CLASSIC')
const selectedCape = ref<Cape | undefined>(undefined)
const props = defineProps<{ capes?: Cape[]; defaultCape?: Cape }>()
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
const visibleCapeList = ref<Cape[]>([])
const sortedCapes = computed(() => {
return [...(props.capes || [])].sort((a, b) => {
const nameA = (a.name || '').toLowerCase()
const nameB = (b.name || '').toLowerCase()
return nameA.localeCompare(nameB)
})
})
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
} else if (currentSkin.value) {
try {
previewSkin.value = await get_normalized_skin_texture(currentSkin.value)
} catch (error) {
console.error('Failed to load skin texture:', error)
previewSkin.value = '/src/assets/skins/steve.png'
}
} else {
previewSkin.value = '/src/assets/skins/steve.png'
}
}
const hasEdits = computed(() => {
if (mode.value !== 'edit') return true
if (uploadedTextureUrl.value) return true
if (!currentSkin.value) return false
if (variant.value !== currentSkin.value.variant) return true
if ((selectedCape.value?.id || null) !== (currentSkin.value.cape_id || null)) return true
return false
})
const disableSave = computed(
() =>
(mode.value === 'new' && !uploadedTextureUrl.value) ||
(mode.value === 'edit' && !hasEdits.value),
)
const saveTooltip = computed(() => {
if (isSaving.value) return 'Saving...'
if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!'
if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!'
return undefined
})
function resetState() {
mode.value = 'new'
currentSkin.value = null
uploadedTextureUrl.value = null
previewSkin.value = ''
variant.value = 'CLASSIC'
selectedCape.value = undefined
visibleCapeList.value = []
shouldRestoreModal.value = false
isSaving.value = false
}
async function show(e: MouseEvent, skin?: Skin) {
mode.value = skin ? 'edit' : 'new'
currentSkin.value = skin ?? null
if (skin) {
variant.value = skin.variant
selectedCape.value = props.capes?.find((c) => c.id === skin.cape_id)
} else {
variant.value = 'CLASSIC'
selectedCape.value = undefined
}
visibleCapeList.value = []
initVisibleCapeList()
await loadPreviewSkin()
modal.value?.show(e)
}
async function showNew(e: MouseEvent, skinTextureUrl: string) {
mode.value = 'new'
currentSkin.value = null
uploadedTextureUrl.value = skinTextureUrl
variant.value = await determineModelType(skinTextureUrl)
selectedCape.value = undefined
visibleCapeList.value = []
initVisibleCapeList()
await loadPreviewSkin()
modal.value?.show(e)
}
async function restoreWithNewTexture(skinTextureUrl: string) {
uploadedTextureUrl.value = skinTextureUrl
await loadPreviewSkin()
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 handleCapeSelected(cape: Cape | undefined) {
selectCape(cape)
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
}
function handleCapeCancel() {
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
}
function openSelectCapeModal(e: MouseEvent) {
if (!selectCapeModal.value) return
shouldRestoreModal.value = true
modal.value?.hide()
setTimeout(() => {
selectCapeModal.value?.show(
e,
currentSkin.value?.texture_key,
selectedCape.value,
previewSkin.value,
variant.value,
)
}, 0)
}
function openUploadSkinModal(e: MouseEvent) {
shouldRestoreModal.value = true
modal.value?.hide()
emit('open-upload-modal', e)
}
function restoreModal() {
if (shouldRestoreModal.value) {
setTimeout(() => {
const fakeEvent = new MouseEvent('click')
modal.value?.show(fakeEvent)
shouldRestoreModal.value = false
}, 500)
}
}
async function save() {
isSaving.value = true
try {
let textureUrl: string
if (uploadedTextureUrl.value) {
textureUrl = uploadedTextureUrl.value
} else {
textureUrl = currentSkin.value!.texture
}
await unequip_skin()
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
if (mode.value === 'new') {
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
emit('saved')
} else {
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
await remove_custom_skin(currentSkin.value!)
emit('saved')
}
hide()
} catch (err) {
handleError(err)
} finally {
isSaving.value = false
}
}
watch([uploadedTextureUrl, currentSkin], async () => {
await loadPreviewSkin()
})
watch(
() => props.capes,
() => {
initVisibleCapeList()
},
{ immediate: true },
)
const emit = defineEmits<{
(event: 'saved'): void
(event: 'deleted', skin: Skin): void
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
}>()
defineExpose({
show,
showNew,
restoreWithNewTexture,
hide,
shouldRestoreModal,
restoreModal,
})
</script>

View File

@ -1,140 +0,0 @@
<script setup lang="ts">
import { useTemplateRef, ref, computed } from 'vue'
import type { Cape, SkinModel } from '@/helpers/skins.ts'
import {
ButtonStyled,
ScrollablePanel,
CapeButton,
CapeLikeTextButton,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { CheckIcon, XIcon } from '@modrinth/assets'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
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>

View File

@ -1,140 +0,0 @@
<template>
<ModalWrapper ref="modal" @on-hide="hide(true)">
<template #title>
<span class="text-lg font-extrabold text-contrast"> Upload skin texture </span>
</template>
<div class="relative">
<div
class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative"
@click="triggerFileInput"
>
<p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2">
<UploadIcon /> Select skin texture file
</p>
<p class="mx-auto mt-0 text-secondary text-sm text-center">
Drag and drop or click here to browse
</p>
<input
ref="fileInput"
type="file"
accept="image/png"
class="hidden"
@change="handleInputFileChange"
/>
</div>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
import { ref, onBeforeUnmount, watch } from 'vue'
import { UploadIcon } from '@modrinth/assets'
import { useNotifications } from '@/store/state'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_dragged_skin_data } from '@/helpers/skins'
const notifications = useNotifications()
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) {
notifications.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>

View File

@ -0,0 +1,368 @@
<script setup>
import { UserIcon, LockIcon, MailIcon } from '@modrinth/assets'
import { Button, Card, Checkbox } from '@modrinth/ui'
import {
DiscordIcon,
GithubIcon,
MicrosoftIcon,
GoogleIcon,
SteamIcon,
GitLabIcon,
} from '@/assets/external'
import { login, login_2fa, create_account, login_pass } from '@/helpers/mr_auth.js'
import { handleError, useNotifications } from '@/store/state.js'
import { ref } from 'vue'
import { handleSevereError } from '@/store/error.js'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const props = defineProps({
callback: {
type: Function,
required: true,
},
})
const modal = ref()
const turnstileToken = ref()
const widgetId = ref()
defineExpose({
show: () => {
modal.value.show()
if (window.turnstile === null || !window.turnstile) {
const script = document.createElement('script')
script.src =
'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback'
script.async = true
script.defer = true
document.head.appendChild(script)
window.onloadTurnstileCallback = loadWidget
} else {
loadWidget()
}
},
})
function loadWidget() {
widgetId.value = window.turnstile.render('#turnstile-container', {
sitekey: '0x4AAAAAAAW3guHM6Eunbgwu',
callback: (token) => (turnstileToken.value = token),
expiredCallback: () => (turnstileToken.value = null),
})
}
function removeWidget() {
if (widgetId.value) {
window.turnstile.remove(widgetId.value)
widgetId.value = null
turnstileToken.value = null
}
}
const loggingIn = ref(true)
const twoFactorFlow = ref(null)
const twoFactorCode = ref('')
const email = ref('')
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const subscribe = ref(true)
async function signInOauth(provider) {
const creds = await login(provider).catch(handleSevereError)
if (creds && creds.type === 'two_factor_required') {
twoFactorFlow.value = creds.flow
} else if (creds && creds.session) {
props.callback()
modal.value.hide()
}
}
async function signIn2fa() {
const creds = await login_2fa(twoFactorCode.value, twoFactorFlow.value).catch(handleError)
if (creds && creds.session) {
props.callback()
modal.value.hide()
}
}
async function signIn() {
const creds = await login_pass(username.value, password.value, turnstileToken.value).catch(
handleError,
)
window.turnstile.reset(widgetId.value)
if (creds && creds.type === 'two_factor_required') {
twoFactorFlow.value = creds.flow
} else if (creds && creds.session) {
props.callback()
modal.value.hide()
}
}
async function createAccount() {
if (password.value !== confirmPassword.value) {
const notifs = useNotifications()
notifs.addNotification({
title: 'An error occurred',
text: 'Passwords do not match!',
type: 'error',
})
return
}
const creds = await create_account(
username.value,
email.value,
password.value,
turnstileToken.value,
subscribe.value,
).catch(handleError)
window.turnstile.reset(widgetId.value)
if (creds && creds.session) {
props.callback()
modal.value.hide()
}
}
</script>
<template>
<ModalWrapper ref="modal" :on-hide="removeWidget">
<Card>
<template v-if="twoFactorFlow">
<h1>Enter two-factor code</h1>
<p>Please enter a two-factor code to proceed.</p>
<input v-model="twoFactorCode" maxlength="11" type="text" placeholder="Enter code..." />
</template>
<template v-else>
<h1 v-if="loggingIn">Login to Modrinth</h1>
<h1 v-else>Create an account</h1>
<div class="button-grid">
<Button class="discord" large @click="signInOauth('discord')">
<DiscordIcon />
Discord
</Button>
<Button class="github" large @click="signInOauth('github')">
<GithubIcon />
Github
</Button>
<Button class="white" large @click="signInOauth('microsoft')">
<MicrosoftIcon />
Microsoft
</Button>
<Button class="google" large @click="signInOauth('google')">
<GoogleIcon />
Google
</Button>
<Button class="white" large @click="signInOauth('steam')">
<SteamIcon />
Steam
</Button>
<Button class="gitlab" large @click="signInOauth('gitlab')">
<GitLabIcon />
GitLab
</Button>
</div>
<div class="divider">
<hr />
<p>Or</p>
</div>
<div v-if="!loggingIn" class="iconified-input username">
<MailIcon />
<input v-model="email" type="text" placeholder="Email" />
</div>
<div class="iconified-input username">
<UserIcon />
<input
v-model="username"
type="text"
:placeholder="loggingIn ? 'Email or username' : 'Username'"
/>
</div>
<div class="iconified-input" :class="{ username: !loggingIn }">
<LockIcon />
<input v-model="password" type="password" placeholder="Password" />
</div>
<div v-if="!loggingIn" class="iconified-input username">
<LockIcon />
<input v-model="confirmPassword" type="password" placeholder="Confirm password" />
</div>
<div class="turnstile">
<div id="turnstile-container"></div>
<div id="turnstile-container-2"></div>
</div>
<Checkbox
v-if="!loggingIn"
v-model="subscribe"
class="subscribe-btn"
label="Subscribe to updates about Modrinth"
/>
<div class="link-row">
<a v-if="loggingIn" class="button-base" @click="loggingIn = false"> Create account </a>
<a v-else class="button-base" @click="loggingIn = true">Sign in</a>
<a class="button-base" href="https://modrinth.com/auth/reset-password">
Forgot password?
</a>
</div>
</template>
<div class="button-row">
<Button class="transparent" large>Close</Button>
<Button v-if="twoFactorCode" color="primary" large @click="signIn2fa"> Login </Button>
<Button
v-else-if="loggingIn"
color="primary"
large
:disabled="!turnstileToken"
@click="signIn"
>
Login
</Button>
<Button v-else color="primary" large :disabled="!turnstileToken" @click="createAccount">
Create account
</Button>
</div>
</Card>
</ModalWrapper>
</template>
<style scoped lang="scss">
:deep(.modal-container) {
.modal-body {
width: auto;
.content {
background: none;
}
}
}
.card {
width: 25rem;
}
.button-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: var(--gap-md);
.btn {
width: 100%;
justify-content: center;
}
.discord {
background-color: #5865f2;
color: var(--color-contrast);
}
.github {
background-color: #8740f1;
color: var(--color-contrast);
}
.white {
background-color: var(--color-contrast);
color: var(--color-accent-contrast);
}
.google {
background-color: #4285f4;
color: var(--color-contrast);
}
.gitlab {
background-color: #fc6d26;
color: var(--color-contrast);
}
}
.divider {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin: var(--gap-md) 0;
p {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--color-raised-bg);
padding: 0 1rem;
margin: 0;
}
hr {
border: none;
width: 100%;
border-top: 2px solid var(--color-button-bg);
}
}
.iconified-input {
width: 100%;
input {
width: 100%;
flex-basis: auto;
}
}
.username {
margin-bottom: var(--gap-sm);
}
.link-row {
display: flex;
justify-content: space-between;
margin: var(--gap-md) 0;
a {
color: var(--color-blue);
text-decoration: underline;
&:hover {
cursor: pointer;
}
}
}
.button-row {
display: flex;
justify-content: space-between;
.btn {
flex-basis: auto;
}
.transparent {
padding: var(--gap-md) 0;
}
}
:deep(.checkbox) {
border: none;
}
.turnstile {
display: flex;
justify-content: center;
overflow: hidden;
border-radius: var(--radius-md);
border: 2px solid var(--color-button-bg);
height: 66px;
margin-top: var(--gap-md);
iframe {
margin: -1px;
min-width: calc(100% + 2px);
}
}
</style>

View File

@ -1,230 +0,0 @@
<script setup lang="ts">
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import {
EyeIcon,
FolderOpenIcon,
MoreVerticalIcon,
PlayIcon,
SpinnerIcon,
StopCircleIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
OverflowMenu,
SmartClickable,
useRelativeTime,
} from '@modrinth/ui'
import { useVIntl } from '@vintl/vintl'
import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue'
import { showProfileInFolder } from '@/helpers/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import { useRouter } from 'vue-router'
import type { GameInstance } from '@/helpers/types'
import { get_project } from '@/helpers/cache'
import { capitalizeString } from '@modrinth/utils'
import { kill, run } from '@/helpers/profile'
import { handleSevereError } from '@/store/error'
import { trackEvent } from '@/helpers/analytics'
import { get_by_profile_path } from '@/helpers/process'
import { handleError } from '@/store/notifications'
import { process_listener } from '@/helpers/events'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const router = useRouter()
const emit = defineEmits<{
(e: 'play' | 'stop'): void
}>()
const props = defineProps<{
instance: GameInstance
last_played: Dayjs
}>()
const loadingModpack = ref(!!props.instance.linked_data)
const modpack = ref()
if (props.instance.linked_data) {
nextTick().then(async () => {
modpack.value = await get_project(props.instance.linked_data?.project_id, 'must_revalidate')
loadingModpack.value = false
})
}
const instanceIcon = computed(() => props.instance.icon_path)
const loader = computed(() => {
if (props.instance.loader === 'vanilla') {
return 'Minecraft'
} else if (props.instance.loader === 'neoforge') {
return 'NeoForge'
} else {
return capitalizeString(props.instance.loader)
}
})
const loading = ref(false)
const playing = ref(false)
const play = async (event: MouseEvent) => {
event?.stopPropagation()
loading.value = true
await run(props.instance.path)
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
.finally(() => {
trackEvent('InstancePlay', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: 'InstanceItem',
})
})
emit('play')
loading.value = false
}
const stop = async (event: MouseEvent) => {
event?.stopPropagation()
loading.value = true
await kill(props.instance.path).catch(handleError)
trackEvent('InstanceStop', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: 'InstanceItem',
})
emit('stop')
loading.value = false
}
const unlistenProcesses = await process_listener(async () => {
await checkProcess()
})
const checkProcess = async () => {
const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError)
playing.value = runningProcesses.length > 0
}
onMounted(() => {
checkProcess()
})
onUnmounted(() => {
unlistenProcesses()
})
</script>
<template>
<SmartClickable>
<template #clickable>
<router-link
class="no-click-animation"
:to="`/instance/${encodeURIComponent(instance.path)}`"
/>
</template>
<div
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised rounded-xl smart-clickable:highlight-on-hover"
>
<Avatar
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
:tint-by="instance.path"
size="48px"
/>
<div class="flex flex-col col-span-2 justify-between h-full">
<div class="flex items-center gap-2">
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
{{ instance.name }}
</div>
</div>
<div class="flex items-center gap-2 text-sm text-secondary">
<div
v-tooltip="
instance.last_played
? dayjs(instance.last_played).format('MMMM D, YYYY [at] h:mm A')
: null
"
class="w-fit shrink-0"
:class="{ 'cursor-help smart-clickable:allow-pointer-events': last_played }"
>
<template v-if="last_played">
{{
formatMessage(commonMessages.playedLabel, {
time: formatRelativeTime(last_played.toISOString?.()),
})
}}
</template>
<template v-else> Not played yet </template>
</div>
<span v-if="modpack" class="flex items-center gap-1 truncate text-secondary">
<router-link
class="inline-flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
:to="`/project/${modpack.id}`"
>
<Avatar :src="modpack.icon_url" size="16px" class="shrink-0" />
<span class="truncate">{{ modpack.title }}</span>
</router-link>
({{ loader }} {{ instance.game_version }})
</span>
<span v-else-if="loadingModpack" class="flex items-center gap-1 truncate text-secondary">
<SpinnerIcon class="animate-spin shrink-0" />
<span class="truncate">Loading modpack...</span>
</span>
<span v-else class="flex items-center gap-1 truncate text-secondary">
{{ loader }}
{{ instance.game_version }}
</span>
</div>
</div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<ButtonStyled v-if="playing && !loading" color="red">
<button @click="stop">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="playing ? 'Instance is already open' : null"
:disabled="playing || loading"
@click="play"
>
<SpinnerIcon v-if="loading" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{
id: 'open-instance',
shown: !!instance.path,
action: () => router.push(encodeURI(`/instance/${instance.path}`)),
},
{
id: 'open-folder',
action: () => showProfileInFolder(instance.path),
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-instance>
<EyeIcon aria-hidden="true" />
View instance
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</SmartClickable>
</template>

View File

@ -1,302 +0,0 @@
<script setup lang="ts">
import {
type ProtocolVersion,
type ServerWorld,
type ServerData,
type WorldWithProfile,
get_recent_worlds,
getWorldIdentifier,
get_profile_protocol_version,
refreshServerData,
start_join_server,
start_join_singleplayer_world,
} from '@/helpers/worlds.ts'
import { HeadingLink, GAME_MODES } from '@modrinth/ui'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import { watch, onMounted, onUnmounted, ref, computed } from 'vue'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { useTheming } from '@/store/theme.ts'
import { kill, run } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { trackEvent } from '@/helpers/analytics'
import { process_listener, profile_listener } from '@/helpers/events'
import { get_all } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { handleSevereError } from '@/store/error'
const props = defineProps<{
recentInstances: GameInstance[]
}>()
const theme = useTheming()
const jumpBackInItems = ref<JumpBackInItem[]>([])
const serverData = ref<Record<string, ServerData>>({})
const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
const MIN_JUMP_BACK_IN = 3
const MAX_JUMP_BACK_IN = 6
const TWO_WEEKS_AGO = dayjs().subtract(14, 'day')
type BaseJumpBackInItem = {
last_played: Dayjs
instance: GameInstance
}
type InstanceJumpBackInItem = BaseJumpBackInItem & {
type: 'instance'
}
type WorldJumpBackInItem = BaseJumpBackInItem & {
type: 'world'
world: WorldWithProfile
}
type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
const showWorlds = computed(() => theme.getFeatureFlag('worlds_in_home'))
watch([() => props.recentInstances, () => showWorlds.value], async () => {
await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in')
})
})
await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in')
})
async function populateJumpBackIn() {
console.info('Repopulating jump back in...')
const worldItems: WorldJumpBackInItem[] = []
if (showWorlds.value) {
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN, ['normal', 'favorite'])
worlds.forEach((world) => {
const instance = props.recentInstances.find((instance) => instance.path === world.profile)
if (!instance || !world.last_played) {
return
}
worldItems.push({
type: 'world',
last_played: dayjs(world.last_played ?? 0),
world: world,
instance: instance,
})
})
const servers: {
instancePath: string
address: string
}[] = worldItems
.filter((item) => item.world.type === 'server' && item.instance)
.map((item) => ({
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.instancePath))
await Promise.all(
[...uniqueServerInstances].map((path) =>
get_profile_protocol_version(path)
.then((protoVer) => (protocolVersions.value[path] = protoVer))
.catch(() => {
console.error(`Failed to get profile protocol for: ${path} `)
}),
),
)
// initialize server data
servers.forEach(({ address }) => {
if (!serverData.value[address]) {
serverData.value[address] = {
refreshing: true,
}
}
})
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.path === instance.path)
if ((worldItem && worldItem.last_played.isAfter(TWO_WEEKS_AGO)) || !instance.last_played) {
continue
}
instanceItems.push({
type: 'instance',
last_played: dayjs(instance.last_played ?? 0),
instance: instance,
})
}
const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
items.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0)))
jumpBackInItems.value = items
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO))
.slice(0, MAX_JUMP_BACK_IN)
}
function refreshServer(address: string, instancePath: string) {
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
}
async function joinWorld(world: WorldWithProfile) {
console.log(`Joining world ${getWorldIdentifier(world)}`)
if (world.type === 'server') {
await start_join_server(world.profile, world.address).catch(handleError)
} else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(world.profile, world.path).catch(handleError)
}
}
async function playInstance(instance: GameInstance) {
await run(instance.path)
.catch((err) => handleSevereError(err, { profilePath: instance.path }))
.finally(() => {
trackEvent('InstancePlay', {
loader: instance.loader,
game_version: instance.game_version,
source: 'WorldItem',
})
})
}
async function stopInstance(path: string) {
await kill(path).catch(handleError)
trackEvent('InstanceStop', {
source: 'RecentWorldsList',
})
}
const currentProfile = ref<string>()
const currentWorld = ref<string>()
const unlistenProcesses = await process_listener(async () => {
await checkProcesses()
})
const unlistenProfiles = await profile_listener(async () => {
await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in')
})
})
const runningInstances = ref<string[]>([])
type ProcessMetadata = {
uuid: string
profile_path: string
start_time: string
}
const checkProcesses = async () => {
const runningProcesses: ProcessMetadata[] = await get_all().catch(handleError)
const runningPaths = runningProcesses.map((x) => x.profile_path)
const stoppedInstances = runningInstances.value.filter((x) => !runningPaths.includes(x))
if (currentProfile.value && stoppedInstances.includes(currentProfile.value)) {
currentProfile.value = undefined
currentWorld.value = undefined
}
runningInstances.value = runningPaths
}
onMounted(() => {
checkProcesses()
})
onUnmounted(() => {
unlistenProcesses()
unlistenProfiles()
})
</script>
<template>
<div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1">
Jump back in
</HeadingLink>
<span
v-else
class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold"
>
Jump back in
</span>
<div class="grid-when-huge flex flex-col w-full gap-2">
<template
v-for="item in jumpBackInItems"
:key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
>
<WorldItem
v-if="item.type === 'world'"
:world="item.world"
:playing-instance="runningInstances.includes(item.instance.path)"
:playing-world="
currentProfile === item.instance.path && currentWorld === getWorldIdentifier(item.world)
"
:refreshing="
item.world.type === 'server'
? serverData[item.world.address].refreshing && !serverData[item.world.address].status
: undefined
"
supports-quick-play
: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.path]"
:game-mode="
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
"
: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.path)
: {}
"
@update="() => populateJumpBackIn()"
@play="
() => {
currentProfile = item.instance.path
currentWorld = getWorldIdentifier(item.world)
joinWorld(item.world)
}
"
@play-instance="
() => {
currentProfile = item.instance.path
playInstance(item.instance)
}
"
@stop="() => stopInstance(item.instance.path)"
/>
<InstanceItem v-else :instance="item.instance" :last_played="item.last_played" />
</template>
</div>
</div>
</template>
<style scoped lang="scss">
.grid-when-huge {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(670px, 1fr));
}
</style>

View File

@ -1,515 +0,0 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import type {
ProtocolVersion,
ServerStatus,
ServerWorld,
SingleplayerWorld,
World,
} from '@/helpers/worlds.ts'
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
import { formatNumber, getPingLevel } from '@modrinth/utils'
import {
useRelativeTime,
Avatar,
ButtonStyled,
commonMessages,
OverflowMenu,
SmartClickable,
} from '@modrinth/ui'
import {
IssuesIcon,
EyeIcon,
ClipboardCopyIcon,
EditIcon,
FolderOpenIcon,
MoreVerticalIcon,
NoSignalIcon,
PlayIcon,
SignalIcon,
SkullIcon,
SpinnerIcon,
StopCircleIcon,
TrashIcon,
UpdatedIcon,
UserIcon,
XIcon,
} from '@modrinth/assets'
import type { MessageDescriptor } from '@vintl/vintl'
import { defineMessages, useVIntl } from '@vintl/vintl'
import type { Component } from 'vue'
import { computed } from 'vue'
import { copyToClipboard } from '@/helpers/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import { useRouter } from 'vue-router'
import { Tooltip } from 'floating-vue'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const router = useRouter()
const emit = defineEmits<{
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void
(e: 'open-folder', world: SingleplayerWorld): void
}>()
const props = withDefaults(
defineProps<{
world: World
playingInstance?: boolean
playingWorld?: boolean
startingInstance?: boolean
supportsServerQuickPlay?: boolean
supportsWorldQuickPlay?: boolean
currentProtocol?: ProtocolVersion | null
highlighted?: boolean
// Server only
refreshing?: boolean
serverStatus?: ServerStatus
renderedMotd?: string
// Singleplayer only
gameMode?: {
icon: Component
message: MessageDescriptor
}
// Instance
instancePath?: string
instanceName?: string
instanceIcon?: string
}>(),
{
playingInstance: false,
playingWorld: false,
startingInstance: false,
supportsServerQuickPlay: true,
supportsWorldQuickPlay: false,
currentProtocol: null,
refreshing: false,
serverStatus: undefined,
renderedMotd: undefined,
gameMode: undefined,
instancePath: undefined,
instanceName: undefined,
instanceIcon: undefined,
},
)
const playingOtherWorld = computed(() => props.playingInstance && !props.playingWorld)
const hasPlayersTooltip = computed(
() => !!props.serverStatus?.players?.sample && props.serverStatus.players?.sample?.length > 0,
)
const serverIncompatible = computed(
() =>
!!props.serverStatus &&
!!props.serverStatus.version?.protocol &&
!!props.currentProtocol &&
(props.serverStatus.version.protocol !== props.currentProtocol.version ||
props.serverStatus.version.legacy !== props.currentProtocol.legacy),
)
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
const messages = defineMessages({
hardcore: {
id: 'instance.worlds.hardcore',
defaultMessage: 'Hardcore mode',
},
cantConnect: {
id: 'instance.worlds.cant_connect',
defaultMessage: "Can't connect to server",
},
aMinecraftServer: {
id: 'instance.worlds.a_minecraft_server',
defaultMessage: 'A Minecraft Server',
},
noServerQuickPlay: {
id: 'instance.worlds.no_server_quick_play',
defaultMessage: 'You can only jump straight into servers on Minecraft Alpha 1.0.5+',
},
noSingleplayerQuickPlay: {
id: 'instance.worlds.no_singleplayer_quick_play',
defaultMessage: 'You can only jump straight into singleplayer worlds on Minecraft 1.20+',
},
gameAlreadyOpen: {
id: 'instance.worlds.game_already_open',
defaultMessage: 'Instance is already open',
},
noContact: {
id: 'instance.worlds.no_contact',
defaultMessage: "Server couldn't be contacted",
},
incompatibleServer: {
id: 'instance.worlds.incompatible_server',
defaultMessage: 'Server is incompatible',
},
copyAddress: {
id: 'instance.worlds.copy_address',
defaultMessage: 'Copy address',
},
viewInstance: {
id: 'instance.worlds.view_instance',
defaultMessage: 'View instance',
},
playInstance: {
id: 'instance.worlds.play_instance',
defaultMessage: 'Play instance',
},
worldInUse: {
id: 'instance.worlds.world_in_use',
defaultMessage: 'World is in use',
},
dontShowOnHome: {
id: 'instance.worlds.dont_show_on_home',
defaultMessage: `Don't show on Home`,
},
})
</script>
<template>
<SmartClickable>
<template v-if="instancePath" #clickable>
<router-link
class="no-click-animation"
:to="`/instance/${encodeURIComponent(instancePath)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world))}`"
/>
</template>
<div
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised smart-clickable:highlight-on-hover rounded-xl"
:class="{
'world-item-highlighted': highlighted,
}"
>
<Avatar
:src="
world.type === 'server' && serverStatus ? serverStatus.favicon ?? world.icon : world.icon
"
size="48px"
/>
<div class="flex flex-col justify-between h-full">
<div class="flex items-center gap-2">
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
{{ world.name }}
</div>
<div
v-if="world.type === 'singleplayer'"
class="text-sm text-secondary flex items-center gap-1 font-semibold"
>
<UserIcon
aria-hidden="true"
class="h-4 w-4 text-secondary shrink-0"
stroke-width="3px"
/>
{{ formatMessage(commonMessages.singleplayerLabel) }}
</div>
<div
v-else-if="world.type === 'server'"
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
>
<template v-if="refreshing">
<SpinnerIcon aria-hidden="true" class="animate-spin shrink-0" />
Loading...
</template>
<template v-else-if="serverStatus">
<template v-if="serverIncompatible">
<IssuesIcon class="shrink-0 text-orange" aria-hidden="true" />
<span class="text-orange">
Incompatible version {{ serverStatus.version?.name }}
</span>
</template>
<template v-else>
<SignalIcon
v-tooltip="serverStatus ? `${serverStatus.ping}ms` : null"
aria-hidden="true"
:style="`--_signal-${getPingLevel(serverStatus.ping || 0)}: var(--color-green)`"
stroke-width="3px"
class="shrink-0"
:class="{
'smart-clickable:allow-pointer-events': serverStatus,
}"
/>
<Tooltip :disabled="!hasPlayersTooltip">
<span :class="{ 'cursor-help': hasPlayersTooltip }">
{{ formatNumber(serverStatus.players?.online, false) }} online
</span>
<template #popper>
<div class="flex flex-col gap-1">
<span v-for="player in serverStatus.players?.sample" :key="player.name">
{{ player.name }}
</span>
</div>
</template>
</Tooltip>
</template>
</template>
<template v-else>
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" /> Offline
</template>
</div>
</div>
<div class="flex items-center gap-2 text-sm text-secondary">
<div
v-tooltip="
world.last_played ? dayjs(world.last_played).format('MMMM D, YYYY [at] h:mm A') : null
"
class="w-fit shrink-0"
:class="{ 'cursor-help smart-clickable:allow-pointer-events': world.last_played }"
>
<template v-if="world.last_played">
{{
formatMessage(commonMessages.playedLabel, {
time: formatRelativeTime(dayjs(world.last_played).toISOString()),
})
}}
</template>
<template v-else> Not played yet </template>
</div>
<template v-if="instancePath">
<router-link
class="flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
:to="`/instance/${instancePath}`"
>
<Avatar
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
size="16px"
:tint-by="instancePath"
class="shrink-0"
/>
<span class="truncate">{{ instanceName }}</span>
</router-link>
</template>
</div>
</div>
<div
class="font-semibold flex items-center gap-1 justify-center text-center"
:class="world.type === 'singleplayer' && world.hardcore ? `text-red` : 'text-secondary'"
>
<template v-if="world.type === 'server'">
<template v-if="refreshing">
<SpinnerIcon aria-hidden="true" class="animate-spin" />
{{ formatMessage(commonMessages.loadingLabel) }}
</template>
<div
v-else-if="renderedMotd"
class="motd-renderer font-normal font-minecraft line-clamp-2 text-secondary leading-5"
v-html="renderedMotd"
/>
<div v-else-if="!serverStatus" class="font-normal font-minecraft text-red leading-5">
{{ formatMessage(messages.cantConnect) }}
</div>
<div v-else class="font-normal font-minecraft text-secondary leading-5">
{{ formatMessage(messages.aMinecraftServer) }}
</div>
</template>
<template v-else-if="world.type === 'singleplayer' && gameMode">
<template v-if="world.hardcore">
<SkullIcon aria-hidden="true" class="h-4 w-4 shrink-0" />
{{ formatMessage(messages.hardcore) }}
</template>
<template v-else>
<component :is="gameMode.icon" aria-hidden="true" class="h-4 w-4 shrink-0" />
{{ formatMessage(gameMode.message) }}
</template>
</template>
</div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<ButtonStyled
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
color="red"
>
<button @click="emit('stop')">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="
world.type == 'server' && !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: world.type == 'singleplayer' && !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: null
"
:disabled="
playingOtherWorld ||
startingInstance ||
(world.type == 'server' && !supportsServerQuickPlay) ||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{
id: 'play-instance',
shown: !!instancePath,
disabled: playingInstance,
action: () => emit('play-instance'),
},
{
id: 'open-instance',
shown: !!instancePath,
action: () => router.push(encodeURI(`/instance/${instancePath}`)),
},
{
id: 'refresh',
shown: world.type === 'server',
action: () => emit('refresh'),
},
{
id: 'copy-address',
shown: world.type === 'server',
action: () => copyToClipboard((world as ServerWorld).address),
},
{
id: 'edit',
action: () => emit('edit'),
shown: !instancePath,
disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
},
{
id: 'open-folder',
shown: world.type === 'singleplayer',
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}),
},
{
divider: true,
shown: !!instancePath,
},
{
id: 'dont-show-on-home',
shown: !!instancePath,
action: () => {
set_world_display_status(
instancePath,
world.type,
getWorldIdentifier(world),
'hidden',
).then(() => {
emit('update')
})
},
},
{
divider: true,
shown: !instancePath,
},
{
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => emit('delete'),
shown: !instancePath,
disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #play-instance>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }}
</template>
<template #open-instance>
<EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }}
</template>
<template #edit>
<EditIcon aria-hidden="true" /> {{ formatMessage(commonMessages.editButton) }}
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
<template #copy-address>
<ClipboardCopyIcon aria-hidden="true" /> {{ formatMessage(messages.copyAddress) }}
</template>
<template #refresh>
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(commonMessages.refreshButton) }}
</template>
<template #dont-show-on-home>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.dontShowOnHome) }}
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
{{
formatMessage(
world.type === 'server'
? commonMessages.removeButton
: commonMessages.deleteLabel,
)
}}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</SmartClickable>
</template>
<style scoped lang="scss">
.world-item-highlighted {
position: relative;
animation: fade-highlight 4s ease-out;
filter: brightness(1);
&::before {
@apply rounded-xl inset-0 absolute;
animation: fade-opacity 4s ease-out;
content: '';
box-shadow: 0 0 8px 2px var(--color-brand);
border: 1.5px solid var(--color-brand);
opacity: 0;
}
}
@keyframes fade-highlight {
0% {
filter: brightness(1.25);
}
75% {
filter: brightness(1.25);
}
100% {
filter: brightness(1);
}
}
@keyframes fade-opacity {
0% {
opacity: 0.5;
}
75% {
opacity: 0.5;
}
100% {
opacity: 0;
}
}
.light-mode .motd-renderer {
filter: brightness(0.75);
}
</style>

View File

@ -1,115 +0,0 @@
<script setup lang="ts">
import { PlayIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types'
import InstanceModalTitlePrefix from '@/components/ui/modal/InstanceModalTitlePrefix.vue'
import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
submit: [server: ServerWorld, play: boolean]
}>()
const props = defineProps<{
instance: GameInstance
}>()
const modal = ref()
const name = ref()
const address = ref()
const resourcePack = ref<ServerPackStatus>('enabled')
async function addServer(play: boolean) {
const serverName = name.value ? name.value : address.value
const resourcePackStatus = resourcePack.value
const index =
(await add_server_to_profile(
props.instance.path,
serverName,
address.value,
resourcePackStatus,
).catch(handleError)) ?? 0
emit(
'submit',
{
name: serverName,
type: 'server',
index,
address: address.value,
pack_status: resourcePackStatus,
},
play,
)
hide()
}
function show() {
name.value = ''
address.value = ''
resourcePack.value = 'enabled'
modal.value.show()
}
function hide() {
modal.value.hide()
}
const messages = defineMessages({
title: {
id: 'instance.add-server.title',
defaultMessage: 'Add a server',
},
addServer: {
id: 'instance.add-server.add-server',
defaultMessage: 'Add server',
},
addAndPlay: {
id: 'instance.add-server.add-and-play',
defaultMessage: 'Add and play',
},
})
defineExpose({ show, hide })
</script>
<template>
<ModalWrapper ref="modal">
<template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<InstanceModalTitlePrefix :instance="instance" />
<span class="font-extrabold text-contrast">{{ formatMessage(messages.title) }}</span>
</span>
</template>
<ServerModalBody
v-model:name="name"
v-model:address="address"
v-model:resource-pack="resourcePack"
/>
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">
<button :disabled="!address" @click="addServer(true)">
<PlayIcon />
{{ formatMessage(messages.addAndPlay) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="!address" @click="addServer(false)">
<PlusIcon />
{{ formatMessage(messages.addServer) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>

View File

@ -1,118 +0,0 @@
<script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages } from '@modrinth/ui'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types'
import {
type ServerPackStatus,
edit_server_in_profile,
type ServerWorld,
set_world_display_status,
type DisplayStatus,
} from '@/helpers/worlds.ts'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
submit: [server: ServerWorld]
}>()
const props = defineProps<{
instance: GameInstance
}>()
const modal = ref()
const name = ref<string>('')
const address = ref<string>('')
const resourcePack = ref<ServerPackStatus>('enabled')
const index = ref<number>(0)
const displayStatus = ref<DisplayStatus>('normal')
const hideFromHome = ref(false)
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
async function saveServer() {
const serverName = name.value ? name.value : address.value
const resourcePackStatus = resourcePack.value
await edit_server_in_profile(
props.instance.path,
index.value,
serverName,
address.value,
resourcePackStatus,
).catch(handleError)
if (newDisplayStatus.value !== displayStatus.value) {
await set_world_display_status(
props.instance.path,
'server',
address.value,
newDisplayStatus.value,
).catch(handleError)
}
emit('submit', {
name: serverName,
type: 'server',
index: index.value,
address: address.value,
pack_status: resourcePackStatus,
display_status: newDisplayStatus.value,
})
hide()
}
function show(server: ServerWorld) {
name.value = server.name
address.value = server.address
resourcePack.value = server.pack_status
index.value = server.index
displayStatus.value = server.display_status
hideFromHome.value = server.display_status === 'hidden'
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show })
const titleMessage = defineMessage({
id: 'instance.edit-server.title',
defaultMessage: 'Edit server',
})
</script>
<template>
<ModalWrapper ref="modal">
<template #title>
<span class="font-extrabold text-lg text-contrast">{{ formatMessage(titleMessage) }}</span>
</template>
<ServerModalBody
v-model:name="name"
v-model:address="address"
v-model:resource-pack="resourcePack"
/>
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">
<button :disabled="!address" @click="saveServer">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>

View File

@ -1,129 +0,0 @@
<script setup lang="ts">
import { ChevronRightIcon, SaveIcon, XIcon, UndoIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages } from '@modrinth/ui'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types'
import type { DisplayStatus, SingleplayerWorld } from '@/helpers/worlds.ts'
import { set_world_display_status, rename_world, reset_world_icon } from '@/helpers/worlds.ts'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
submit: [path: string, name: string, removeIcon: boolean, displayStatus: DisplayStatus]
}>()
const props = defineProps<{
instance: GameInstance
}>()
const modal = ref()
const icon = ref()
const name = ref()
const path = ref()
const removeIcon = ref(false)
const displayStatus = ref<DisplayStatus>('normal')
const hideFromHome = ref(false)
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
async function saveWorld() {
await rename_world(props.instance.path, path.value, name.value).catch(handleError)
if (removeIcon.value) {
await reset_world_icon(props.instance.path, path.value).catch(handleError)
}
if (newDisplayStatus.value !== displayStatus.value) {
await set_world_display_status(
props.instance.path,
'singleplayer',
path.value,
newDisplayStatus.value,
)
}
emit('submit', path.value, name.value, removeIcon.value, newDisplayStatus.value)
hide()
}
function show(world: SingleplayerWorld) {
name.value = world.name
path.value = world.path
icon.value = world.icon
displayStatus.value = world.display_status
hideFromHome.value = world.display_status === 'hidden'
removeIcon.value = false
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show })
const messages = defineMessages({
title: {
id: 'instance.edit-world.title',
defaultMessage: 'Edit world',
},
name: {
id: 'instance.edit-world.name',
defaultMessage: 'Name',
},
placeholderName: {
id: 'instance.edit-world.placeholder-name',
defaultMessage: 'Minecraft World',
},
resetIcon: {
id: 'instance.edit-world.reset-icon',
defaultMessage: 'Reset icon',
},
})
</script>
<template>
<ModalWrapper ref="modal">
<template #title>
<Avatar :src="removeIcon || !icon ? undefined : icon" size="24px" />
{{ instance.name }} <ChevronRightIcon />
<span class="font-extrabold text-lg text-contrast">{{ formatMessage(messages.title) }}</span>
</template>
<div class="w-[450px]">
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
{{ formatMessage(messages.name) }}
</h2>
<input
v-model="name"
type="text"
:placeholder="formatMessage(messages.placeholderName)"
class="w-full"
autocomplete="off"
/>
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
</div>
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">
<button @click="saveWorld">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="removeIcon || !icon" @click="removeIcon = true">
<UndoIcon />
{{ formatMessage(messages.resetIcon) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>

View File

@ -1,18 +0,0 @@
<script setup lang="ts">
import { defineMessage, useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
import { Checkbox } from '@modrinth/ui'
const { formatMessage } = useVIntl()
const value = defineModel<boolean>({ required: true })
const labelMessage = defineMessage({
id: 'instance.edit-world.hide-from-home',
defaultMessage: `Hide from the Home page`,
})
const label = computed(() => formatMessage(labelMessage))
</script>
<template>
<Checkbox v-model="value" :label="label" />
</template>

View File

@ -1,86 +0,0 @@
<script setup lang="ts">
import { TeleportDropdownMenu } from '@modrinth/ui'
import type { ServerPackStatus } from '@/helpers/worlds.ts'
import { type MessageDescriptor, defineMessages, useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
const name = defineModel<string>('name')
const address = defineModel<string>('address')
const resourcePack = defineModel<ServerPackStatus>('resourcePack')
const resourcePackOptions: ServerPackStatus[] = ['enabled', 'prompt', 'disabled']
const resourcePackOptionMessages: Record<ServerPackStatus, MessageDescriptor> = defineMessages({
enabled: {
id: 'instance.add-server.resource-pack.enabled',
defaultMessage: 'Enabled',
},
prompt: {
id: 'instance.add-server.resource-pack.prompt',
defaultMessage: 'Prompt',
},
disabled: {
id: 'instance.add-server.resource-pack.disabled',
defaultMessage: 'Disabled',
},
})
const messages = defineMessages({
name: {
id: 'instance.server-modal.name',
defaultMessage: 'Name',
},
address: {
id: 'instance.server-modal.address',
defaultMessage: 'Address',
},
resourcePack: {
id: 'instance.server-modal.resource-pack',
defaultMessage: 'Resource pack',
},
placeholderName: {
id: 'instance.server-modal.placeholder-name',
defaultMessage: 'Minecraft Server',
},
})
defineExpose({ resourcePackOptions })
</script>
<template>
<div class="w-[450px]">
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
{{ formatMessage(messages.name) }}
</h2>
<input
v-model="name"
type="text"
:placeholder="formatMessage(messages.placeholderName)"
class="w-full"
autocomplete="off"
/>
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
{{ formatMessage(messages.address) }}
</h2>
<input
v-model="address"
type="text"
placeholder="example.modrinth.gg"
class="w-full"
autocomplete="off"
/>
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
{{ formatMessage(messages.resourcePack) }}
</h2>
<div>
<TeleportDropdownMenu
v-model="resourcePack"
:options="resourcePackOptions"
name="Server resource pack"
:display-name="
(option: ServerPackStatus) => formatMessage(resourcePackOptionMessages[option])
"
/>
</div>
</div>
</template>

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