Compare commits

..

10 Commits

Author SHA1 Message Date
Alejandro González
61c2ce2107
Fix lint checks 2025-04-26 23:27:15 +02:00
Alejandro González
d37c634216
Merge branch 'main' into app-users-orgs-and-more 2025-04-26 23:22:34 +02:00
Prospector
cd2fcc06fe
Merge branch 'main' into app-users-orgs-and-more 2025-03-03 12:48:04 -08:00
Prospector
a731f4758e Remove unused code 2025-02-21 14:18:56 -08:00
Prospector
0e0fce0e66 Extract intl, fix lint 2025-02-21 14:17:28 -08:00
Prospector
ea2f97ae23 Add flag for new projects list on user page, update background gradient colors 2025-02-21 14:17:28 -08:00
Prospector
2a0722d0d0 Remove old prop and fix error with card actions on pages with instance context 2025-02-21 14:17:28 -08:00
Prospector
adf213f32a Begin collections implementation 2025-02-21 14:17:28 -08:00
Prospector
54f408dc6c Fix user link in content page 2025-02-21 14:17:28 -08:00
Prospector
c82c4ddc5b Add users, orgs, and new project cards to App 2025-02-21 14:17:28 -08:00
1388 changed files with 27473 additions and 58588 deletions

View File

@ -1,9 +1,6 @@
# 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"
rustflags = ["-C", "link-args=/STACK:16777220"]
[build]
rustflags = ["--cfg", "tokio_unstable"]
rustflags = ["--cfg", "tokio_unstable"]

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

34
.gitattributes vendored
View File

@ -1,35 +1 @@
* 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

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

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 22 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

@ -22,26 +22,23 @@ jobs:
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
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
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

@ -18,28 +18,30 @@ on:
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
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
id: docker_build
uses: docker/build-push-action@v2
env:
SQLX_OFFLINE: true
with:
file: ./apps/labrinth/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/labrinth:main
cache-to: type=inline

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,157 @@
name: Modrinth App release
name: 'Modrinth App build'
on:
push:
branches:
- main
tags:
- 'v*'
paths:
- .github/workflows/theseus-release.yml
- 'apps/app/**'
- 'apps/app-frontend/**'
- 'apps/labrinth/src/common/**'
- 'apps/labrinth/Cargo.toml'
- '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/*.AppImage
!target/release/bundle/appimage/*.AppImage.tar.gz
!target/release/bundle/appimage/*.AppImage.tar.gz.sig
!target/release/bundle/deb/*.deb
!target/release/bundle/rpm/*.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,7 +2,7 @@ name: CI
on:
push:
branches: [main]
branches: ['main']
pull_request:
types: [opened, synchronize]
merge_group:
@ -10,78 +10,71 @@ on:
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

1
.idea/code.iml generated
View File

@ -10,6 +10,7 @@
<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/rust-common/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>

2
.idea/modules.xml generated
View File

@ -5,4 +5,4 @@
<module fileurl="file://$PROJECT_DIR$/.idea/code.iml" filepath="$PROJECT_DIR$/.idea/code.iml" />
</modules>
</component>
</project>
</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

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

5776
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,240 +1,25 @@
[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',
'./packages/ariadne',
]
[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 = "51907c6" }

View File

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

View File

@ -1,7 +1,7 @@
{
"name": "@modrinth/app-frontend",
"private": true,
"version": "1.0.0-local",
"version": "0.9.3",
"type": "module",
"scripts": {
"dev": "vite",
@ -9,31 +9,25 @@
"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"
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{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"
},
"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.1.1",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-opener": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.3.0",
"@tauri-apps/plugin-window-state": "^2.2.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-multiselect": "3.0.0",
@ -44,12 +38,11 @@
"@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",

View File

@ -1,9 +1,9 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import {
UserIcon,
ArrowBigUpDashIcon,
ChangeSkinIcon,
CompassIcon,
DownloadIcon,
HomeIcon,
@ -17,23 +17,14 @@ import {
RestoreIcon,
RightArrowIcon,
SettingsIcon,
WorldIcon,
XIcon,
NewspaperIcon,
} from '@modrinth/assets'
import {
Avatar,
Button,
ButtonStyled,
Notifications,
OverflowMenu,
NewsArticleCard,
} from '@modrinth/ui'
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
import { useLoading, useTheming } from '@/store/state'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { get } from '@/helpers/settings.ts'
import { get } from '@/helpers/settings'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
@ -61,17 +52,15 @@ import { renderString } from '@modrinth/utils'
import { useFetch } from '@/helpers/fetch.js'
import { check } from '@tauri-apps/plugin-updater'
import NavButton from '@/components/ui/NavButton.vue'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import dayjs from 'dayjs'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
const themeStore = useTheming()
@ -178,48 +167,21 @@ async function setupApp() {
`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
'criticalAnnouncements',
true,
)
.then((response) => response.json())
.then((res) => {
if (res && res.header && res.body) {
criticalErrorMessage.value = res
}
})
.catch(() => {
console.log(
`No critical announcement found at https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
)
})
).then((res) => {
if (res && res.header && res.body) {
criticalErrorMessage.value = res
}
})
useFetch(`https://modrinth.com/news/feed/articles.json`, 'news', true)
.then((response) => response.json())
.then((res) => {
if (res && res.articles) {
// Format expected by NewsArticleCard component.
news.value = res.articles
.map((article) => ({
...article,
path: article.link,
thumbnail: article.thumbnail,
title: article.title,
summary: article.summary,
date: article.date,
}))
.slice(0, 4)
}
})
useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => {
if (res && res.articles) {
news.value = res.articles
}
})
get_opening_command().then(handleCommand)
checkUpdates()
fetchCredentials()
try {
const skins = (await get_available_skins()) ?? []
const capes = (await get_available_capes()) ?? []
generateSkinPreviews(skins, capes)
} catch (error) {
console.warn('Failed to generate skin previews in app setup.', error)
}
}
const stateFailed = ref(false)
@ -264,8 +226,6 @@ const incompatibilityWarningModal = ref()
const credentials = ref()
const modrinthLoginFlowWaitModal = ref()
async function fetchCredentials() {
const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) {
@ -274,25 +234,12 @@ async function fetchCredentials() {
credentials.value = creds
}
async function signIn() {
modrinthLoginFlowWaitModal.value.show()
const profileMenu = ref()
const isProfileMenuOpen = computed(() => profileMenu.value?.isOpen)
try {
await login()
await fetchCredentials()
} catch (error) {
if (
typeof error === 'object' &&
typeof error['message'] === 'string' &&
error.message.includes('Login canceled')
) {
// Not really an error due to being a result of user interaction, show nothing
} else {
handleError(error)
}
} finally {
modrinthLoginFlowWaitModal.value.hide()
}
async function signIn() {
await login().catch(handleError)
await fetchCredentials()
}
async function logOut() {
@ -345,7 +292,6 @@ onMounted(() => {
})
const accounts = ref(null)
provide('accountsCard', accounts)
command_listener(handleCommand)
async function handleCommand(e) {
@ -417,13 +363,10 @@ function handleAuxClick(e) {
<template>
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<div id="teleports"></div>
<div v-if="stateInitialized" class="app-grid-layout experimental-styles-within relative">
<div v-if="stateInitialized" class="app-grid-layout relative">
<Suspense>
<AppSettingsModal ref="settingsModal" />
</Suspense>
<Suspense>
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
</Suspense>
<Suspense>
<InstanceCreationModal ref="installationModal" />
</Suspense>
@ -433,9 +376,6 @@ function handleAuxClick(e) {
<NavButton v-tooltip.right="'Home'" to="/">
<HomeIcon />
</NavButton>
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
<WorldIcon />
</NavButton>
<NavButton
v-tooltip.right="'Discover content'"
to="/browse/modpack"
@ -444,9 +384,6 @@ function handleAuxClick(e) {
>
<CompassIcon />
</NavButton>
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
<ChangeSkinIcon />
</NavButton>
<NavButton
v-tooltip.right="'Library'"
to="/library"
@ -477,26 +414,34 @@ function handleAuxClick(e) {
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
<SettingsIcon />
</NavButton>
<ButtonStyled v-if="credentials" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'sign-out',
action: () => logOut(),
color: 'danger',
},
]"
direction="left"
>
<Avatar
:src="credentials.user.avatar_url"
:alt="credentials.user.username"
size="32px"
circle
/>
<template #sign-out> <LogOutIcon /> Sign out </template>
</OverflowMenu>
</ButtonStyled>
<OverflowMenu
v-if="credentials"
ref="profileMenu"
placement="right-end"
class="w-12 h-12 border-none cursor-pointer rounded-full flex items-center justify-center text-2xl transition-all button-animation"
:class="isProfileMenuOpen ? 'bg-button-bg' : 'bg-transparent hover:bg-button-bg'"
:options="[
{
id: 'profile',
action: () => router.push(`/user/${credentials.user.id}`),
},
{
id: 'sign-out',
action: () => logOut(),
color: 'danger',
},
]"
direction="left"
>
<Avatar
:src="credentials.user.avatar_url"
:alt="credentials.user.username"
size="32px"
circle
/>
<template #profile> <UserIcon /> Profile </template>
<template #sign-out> <LogOutIcon /> Sign out </template>
</OverflowMenu>
<NavButton v-else v-tooltip.right="'Sign in'" :to="() => signIn()">
<LogInIcon />
<template #label>Sign in</template>
@ -507,13 +452,13 @@ function handleAuxClick(e) {
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
<div class="flex items-center gap-1 ml-3">
<button
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.back()"
>
<LeftArrowIcon />
</button>
<button
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.forward()"
>
<RightArrowIcon />
@ -627,20 +572,34 @@ function handleAuxClick(e) {
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
</suspense>
</div>
<div v-if="news && news.length > 0" class="pt-4 flex flex-col items-center">
<h3 class="px-4 text-lg m-0 text-left w-full">News</h3>
<div class="px-4 pt-2 space-y-4 flex flex-col items-center w-full">
<NewsArticleCard
v-for="(item, index) in news"
:key="`news-${index}`"
:article="item"
<div v-if="news && news.length > 0" class="pt-4 flex flex-col">
<h3 class="px-4 text-lg m-0">News</h3>
<template v-for="(item, index) in news" :key="`news-${index}`">
<a
:class="`flex flex-col outline-offset-[-4px] hover:bg-[--brand-gradient-border] focus:bg-[--brand-gradient-border] px-4 transition-colors ${index === 0 ? 'pt-2 pb-4' : 'py-4'}`"
:href="item.link"
target="_blank"
rel="external"
>
<img
:src="item.thumbnail"
alt="News thumbnail"
aria-hidden="true"
class="w-full aspect-[3/1] object-cover rounded-2xl border-[1px] border-solid border-[--brand-gradient-border]"
/>
<h4 class="mt-2 mb-0 text-sm leading-none text-contrast font-semibold">
{{ item.title }}
</h4>
<p class="my-1 text-sm text-secondary leading-tight">{{ item.summary }}</p>
<p class="text-right text-sm text-secondary opacity-60 leading-tight m-0">
{{ dayjs(item.date).fromNow() }}
</p>
</a>
<hr
v-if="index !== news.length - 1"
class="h-px my-[-2px] mx-4 border-0 m-0 bg-[--brand-gradient-border]"
/>
<ButtonStyled color="brand" size="large">
<a href="https://modrinth.com/news" target="_blank" class="my-4">
<NewspaperIcon /> View all news
</a>
</ButtonStyled>
</div>
</template>
</div>
</div>
</div>
@ -751,6 +710,9 @@ function handleAuxClick(e) {
.app-grid-navbar {
grid-area: nav;
// Fixes SVG scaling issues
filter: brightness(1.00001);
}
.app-grid-statusbar {
@ -834,6 +796,7 @@ function handleAuxClick(e) {
height: 100%;
overflow: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
}
.app-contents::before {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 937 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);

View File

@ -136,7 +136,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)
})
}
@ -213,17 +213,6 @@ 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
})

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'
@ -25,7 +26,6 @@ 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 +44,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)
@ -183,34 +181,31 @@ const maxInstancesPerRow = ref(1)
const maxProjectsPerRow = ref(1)
const calculateCardsPerRow = () => {
if (rows.value.length === 0) {
return
}
if (rows.value && rows.value[0]) {
// 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)
// 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)
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
if (maxInstancesPerRow.value < 5) {
maxInstancesPerRow.value *= 2
}
if (maxInstancesPerCompactRow.value < 5) {
maxInstancesPerCompactRow.value *= 2
}
if (maxProjectsPerRow.value < 3) {
maxProjectsPerRow.value *= 2
}
}
}
const rowContainer = ref(null)
const resizeObserver = ref(null)
onMounted(() => {
calculateCardsPerRow()
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
@ -238,10 +233,17 @@ onUnmounted(() => {
@proceed="deleteProfile"
/>
<div ref="rowContainer" class="flex flex-col gap-4">
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
<HeadingLink class="mt-1" :to="row.route">
<div v-for="(row, rowIndex) in actualInstances" ref="rows" :key="row.label" class="row">
<router-link
class="flex mb-3 leading-none items-center gap-1 text-primary text-lg font-bold hover:underline group"
:class="{ 'mt-1': rowIndex > 0 }"
:to="row.route"
>
{{ row.label }}
</HeadingLink>
<ChevronRightIcon
class="h-5 w-5 stroke-[3px] group-hover:translate-x-1 transition-transform group-hover:text-brand"
/>
</router-link>
<section
v-if="row.instance"
ref="modsRow"

View File

@ -9,11 +9,13 @@
<Avatar
size="36px"
: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>{{ selectedAccount ? selectedAccount.username : 'Select account' }}</span>
<span class="text-secondary text-xs">Minecraft account</span>
</div>
<DropdownIcon class="w-5 h-5 shrink-0" />
@ -26,40 +28,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 +63,7 @@
</template>
<script setup>
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon } from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import {
@ -87,8 +77,6 @@ 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'
defineProps({
mode: {
@ -101,86 +89,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 +123,6 @@ async function login() {
}
trackEvent('AccountLogIn')
loginDisabled.value = false
}
const logout = async (id) => {

View File

@ -19,7 +19,6 @@
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))

View File

@ -14,7 +14,7 @@ import { ref, computed } 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'
@ -92,7 +92,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' })
@ -219,8 +219,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>

View File

@ -2,14 +2,14 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
DownloadIcon,
GameIcon,
PlayIcon,
SpinnerIcon,
StopCircleIcon,
GameIcon,
TimerIcon,
StopCircleIcon,
PlayIcon,
DownloadIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, useRelativeTime } from '@modrinth/ui'
import { ButtonStyled, Avatar, SmartClickable } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { finish_install, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
@ -19,8 +19,10 @@ import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
import { trackEvent } from '@/helpers/analytics'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { formatCategory } from '@modrinth/utils'
const formatRelativeTime = useRelativeTime()
dayjs.extend(relativeTime)
const props = defineProps({
instance: {
@ -132,22 +134,26 @@ 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"
>
<SmartClickable class="card-shadow bg-bg-raised rounded-xl" @mouseenter="checkProcess">
<template #clickable>
<router-link
class="no-click-animation"
:to="`/instance/${encodeURIComponent(instance.path)}/`"
/>
</template>
<div v-if="compact" class="grid grid-cols-[auto_1fr_auto] p-3 pl-4 gap-2">
<Avatar
size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.path"
alt="Mod card"
/>
<div class="h-full flex items-center font-bold text-contrast leading-normal">
<div
class="h-full flex items-center font-bold text-contrast leading-normal smart-clickable:underline-on-hover"
>
<span class="line-clamp-2">{{ instance.name }}</span>
</div>
<div class="flex items-center">
<div class="flex items-center smart-clickable:allow-pointer-events">
<ButtonStyled v-if="playing" color="red" circular @mousehover="checkProcess">
<button v-tooltip="'Stop'" @click="(e) => stop(e, 'InstanceCard')">
<StopCircleIcon />
@ -171,21 +177,10 @@ onUnmounted(() => unlisten())
</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>
<span class="text-sm"> Played {{ dayjs(instance.last_played).fromNow() }} </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 v-else class="p-4 rounded-xl flex gap-3 group" @mouseenter="checkProcess">
<div class="relative flex items-center justify-center">
<Avatar
size="48px"
@ -234,16 +229,18 @@ onUnmounted(() => unlisten())
</div>
</div>
<div class="flex flex-col gap-1">
<p class="m-0 text-md font-bold text-contrast leading-tight line-clamp-1">
<p
class="m-0 text-md font-bold text-contrast leading-tight line-clamp-1 smart-clickable:underline-on-hover"
>
{{ instance.name }}
</p>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
<GameIcon class="shrink-0" />
<span class="text-sm capitalize">
{{ instance.loader }} {{ instance.game_version }}
<span class="text-sm">
{{ formatCategory(instance.loader) }} {{ instance.game_version }}
</span>
</div>
</div>
</div>
</div>
</SmartClickable>
</template>

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>
@ -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')

View File

@ -3,23 +3,18 @@ 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
}
import type { GameInstance } from '@/helpers/types'
defineProps<{
instance: Instance
instance?: GameInstance
}>()
</script>
<template>
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
<div
v-if="instance"
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"
@ -49,5 +44,3 @@ defineProps<{
</ButtonStyled>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -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,

View File

@ -70,7 +70,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

View File

@ -7,7 +7,7 @@
'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"
class="w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
>
<slot />
</RouterLink>

View File

@ -1,16 +1,13 @@
<script setup>
import { Avatar, TagItem } from '@modrinth/ui'
import { Avatar, SmartClickable, TagItem } from '@modrinth/ui'
import { DownloadIcon, HeartIcon, TagIcon } from '@modrinth/assets'
import { formatNumber, formatCategory } from '@modrinth/utils'
import { computed } from 'vue'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useRouter } from 'vue-router'
dayjs.extend(relativeTime)
const router = useRouter()
const props = defineProps({
project: {
type: Object,
@ -21,11 +18,14 @@ const props = defineProps({
})
const featuredCategory = computed(() => {
if (props.project.display_categories.includes('optimization')) {
if (props.project.categories.includes('optimization')) {
return 'optimization'
}
return props.project.display_categories[0] ?? props.project.categories[0]
if (props.project.categories.length > 0) {
return props.project.categories[0]
}
return undefined
})
const toColor = computed(() => {
@ -37,29 +37,15 @@ const toColor = computed(() => {
const r = (color >>> 16) & 0xff
return 'rgba(' + [r, g, b, 1].join(',') + ')'
})
const toTransparent = computed(() => {
let color = props.project.color
color >>>= 0
const b = color & 0xff
const g = (color >>> 8) & 0xff
const r = (color >>> 16) & 0xff
return (
'linear-gradient(rgba(' +
[r, g, b, 0.03].join(',') +
'), 65%, rgba(' +
[r, g, b, 0.3].join(',') +
'))'
)
})
</script>
<template>
<div
<SmartClickable
class="card-shadow bg-bg-raised rounded-xl overflow-clip cursor-pointer hover:brightness-90 transition-all"
@click="router.push(`/project/${project.slug}`)"
>
<template #clickable>
<router-link class="no-click-animation" :to="`/project/${project.slug}`" />
</template>
<div
class="w-full aspect-[2/1] bg-cover bg-center bg-no-repeat"
:style="{
@ -70,21 +56,13 @@ const toTransparent = computed(() => {
'https://launcher-files.modrinth.com/assets/maze-bg.png'
})`,
}"
>
<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>
></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">
<div
class="h-full flex items-center font-bold text-contrast leading-normal smart-clickable:underline-on-hover"
>
<span class="line-clamp-2">{{ project.title }}</span>
</div>
</div>
@ -112,7 +90,7 @@ const toTransparent = computed(() => {
</div>
</div>
</div>
</div>
</SmartClickable>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,194 @@
<script setup lang="ts">
import {
VersionIcon,
ImageIcon,
BookmarkIcon,
DownloadIcon,
HeartIcon,
MoreVerticalIcon,
ExternalIcon,
LinkIcon,
ReportIcon,
SpinnerIcon,
CheckIcon,
} from '@modrinth/assets'
import { ButtonStyled, commonMessages, OverflowMenu } from '@modrinth/ui'
import type { GameInstance } from '@/helpers/types'
import { computed, ref, type Ref } from 'vue'
import { install as installVersion } from '@/store/install'
import { useVIntl, defineMessages } from '@vintl/vintl'
import { isSearchResult, type Project, type SearchResult } from '@modrinth/utils'
import type { InstanceContentMap } from '@/composables/instance-context.ts'
const { formatMessage } = useVIntl()
const props = defineProps<{
project: Project | SearchResult
instance?: GameInstance
instanceContent?: InstanceContentMap
}>()
const installing = ref(false)
const installed: Ref<boolean> = ref(false)
function checkInstallStatus() {
if (props.instanceContent) {
installed.value = Object.values(props.instanceContent).some(
(content) => content.metadata?.project_id === projectId.value,
)
}
}
async function install(toInstance: boolean) {
if (toInstance) {
installing.value = true
}
await installVersion(
projectId.value,
null,
props.instance && toInstance ? props.instance.path : null,
'SearchCard',
() => {
if (toInstance) {
installing.value = false
installed.value = true
}
},
)
}
const modpack = computed(() => props.project.project_type === 'modpack')
const projectWebUrl = computed(
() => `https://modrinth.com/${props.project.project_type}/${props.project.slug}`,
)
const tooltip = defineMessages({
installing: {
id: 'project.card.actions.installing.tooltip',
defaultMessage: 'This project is being installed',
},
installed: {
id: 'project.card.actions.installed.tooltip',
defaultMessage: 'This project is already installed',
},
})
const messages = defineMessages({
viewVersions: {
id: 'project.card.actions.view-versions',
defaultMessage: 'View versions',
},
viewGallery: {
id: 'project.card.actions.view-gallery',
defaultMessage: 'View gallery',
},
})
const projectId = computed(() =>
isSearchResult(props.project) ? props.project.project_id : props.project.id,
)
const copyText = (text: string) => {
navigator.clipboard.writeText(text)
}
checkInstallStatus()
</script>
<template>
<ButtonStyled color="brand">
<button
v-tooltip="
installing
? formatMessage(tooltip.installing)
: installed
? formatMessage(tooltip.installed)
: null
"
:disabled="installing || installed"
@click="() => install(true)"
>
<SpinnerIcon v-if="installing" />
<CheckIcon v-else-if="installed" />
<DownloadIcon v-else />
{{
formatMessage(
installing
? commonMessages.installingButton
: installed
? commonMessages.installedButton
: commonMessages.installButton,
)
}}
</button>
</ButtonStyled>
<!-- TODO: Add in later -->
<ButtonStyled v-if="false" circular>
<button v-tooltip="'Follow'">
<HeartIcon />
</button>
</ButtonStyled>
<ButtonStyled v-if="false" circular>
<button v-tooltip="'Save'">
<BookmarkIcon />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{
id: 'install-elsewhere',
color: 'primary',
action: () => install(false),
shown: !!instance && !modpack,
},
{
divider: true,
shown: !!instance && !modpack,
},
{
id: 'versions',
link: `/project/${projectId}/versions`,
},
{
id: 'gallery',
link: `/project/${projectId}/gallery`,
shown: (project.gallery?.length ?? 0) > 0,
},
{
id: 'open-link',
link: projectWebUrl,
},
{
id: 'copy-link',
action: () => copyText(projectWebUrl),
},
{
divider: true,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
action: () => {},
},
]"
>
<MoreVerticalIcon />
<template #install-elsewhere>
<DownloadIcon /> {{ formatMessage(commonMessages.installToButton) }}
</template>
<template #versions> <VersionIcon /> {{ formatMessage(messages.viewVersions) }} </template>
<template #gallery> <ImageIcon /> {{ formatMessage(messages.viewGallery) }} </template>
<template #open-link>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #copy-link>
<LinkIcon /> {{ formatMessage(commonMessages.copyLinkButton) }}
</template>
<template #report> <ReportIcon /> {{ formatMessage(commonMessages.reportButton) }} </template>
</OverflowMenu>
</ButtonStyled>
</template>

View File

@ -32,33 +32,8 @@ function updateAdPosition() {
<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 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>
</div>
</div>
</template>
<style lang="scss" scoped>
.light,
.light-mode {
.dark-image {
display: none;
}
.light-image {
display: block;
}
}
</style>

View File

@ -30,7 +30,7 @@ const getInstances = async () => {
return dateB - dateA
})
.slice(0, 3)
.slice(0, 4)
}
await getInstances()

View File

@ -14,10 +14,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

View File

@ -1,160 +1,72 @@
<template>
<div
class="card-shadow p-4 bg-bg-raised rounded-xl flex gap-3 group cursor-pointer hover:brightness-90 transition-all"
@click="
() => {
emit('open')
$router.push({
path: `/project/${project.project_id ?? project.id}`,
<NewProjectCard
:project="project"
:link="
asLink(
{
path: `/project/${projectId}`,
query: { i: props.instance ? props.instance.path : undefined },
})
}
},
() => emit('open'),
)
"
:experimental-colors="themeStore.featureFlags.project_card_background"
:creator-link="
creator
? asLink(
{
path: `/user/${creator}`,
query: { i: props.instance ? props.instance.path : undefined },
},
() => emit('open'),
)
: undefined
"
>
<div class="icon w-[96px] h-[96px] relative">
<Avatar :src="project.icon_url" size="96px" class="search-icon origin-top transition-all" />
</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>
<div class="m-0 line-clamp-2">
{{ 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 #actions>
<ButtonStyled color="brand">
<button
:disabled="installed || installing"
class="shrink-0 no-wrap"
@click.stop="install()"
>
<template v-if="project.client_side === 'optional' && project.server_side === 'optional'">
Client or server
<template v-if="!installed">
<DownloadIcon />
</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>
</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>
<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>
<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>
</div>
</div>
<CheckIcon v-else />
{{ installing ? 'Installing' : installed ? 'Installed' : 'Install' }}
</button>
</ButtonStyled>
</template>
</NewProjectCard>
</template>
<script setup>
import { TagsIcon, DownloadIcon, HeartIcon, PlusIcon, CheckIcon } from '@modrinth/assets'
import { ButtonStyled, Avatar } from '@modrinth/ui'
import { formatNumber, formatCategory } from '@modrinth/utils'
<script setup lang="ts">
import { DownloadIcon, CheckIcon } from '@modrinth/assets'
import { ButtonStyled, NewProjectCard, asLink } from '@modrinth/ui'
import type { Project, SearchResult } from '@modrinth/utils'
import { isSearchResult } from '@modrinth/utils'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { ref, computed } from 'vue'
import { install as installVersion } from '@/store/install.js'
import { useRouter } from 'vue-router'
import { useTheming } from '@/store/state.js'
import type { GameInstance } from '@/helpers/types'
dayjs.extend(relativeTime)
const router = useRouter()
const themeStore = useTheming()
const props = defineProps({
backgroundImage: {
type: String,
default: null,
const props = withDefaults(
defineProps<{
project: Project | SearchResult
instance?: GameInstance
installed?: boolean
}>(),
{
instance: undefined,
installed: false,
},
project: {
type: Object,
required: true,
},
categories: {
type: Array,
required: true,
},
instance: {
type: Object,
default: null,
},
featured: {
type: Boolean,
default: false,
},
installed: {
type: Boolean,
default: false,
},
})
)
const emit = defineEmits(['open', 'install'])
@ -163,19 +75,19 @@ const installing = ref(false)
async function install() {
installing.value = true
await installVersion(
props.project.project_id ?? props.project.id,
projectId.value,
null,
props.instance ? props.instance.path : null,
'SearchCard',
() => {
installing.value = false
emit('install', props.project.project_id ?? props.project.id)
},
(profile) => {
router.push(`/instance/${profile}`)
emit('install', projectId.value)
},
)
}
const modpack = computed(() => props.project.project_type === 'modpack')
const projectId = computed(() =>
isSearchResult(props.project) ? props.project.project_id : props.project.id,
)
const creator = computed(() => (isSearchResult(props.project) ? props.project.author : undefined))
</script>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { Avatar, ButtonStyled, OverflowMenu, useRelativeTime } from '@modrinth/ui'
import { Avatar, ButtonStyled, OverflowMenu } from '@modrinth/ui'
import {
UserPlusIcon,
MoreVerticalIcon,
@ -18,8 +18,6 @@ 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
@ -207,9 +205,7 @@ onUnmounted(() => {
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>
<p class="m-0 text-sm text-secondary">{{ friend.created.fromNow() }}</p>
</div>
<div class="flex gap-2">
<template v-if="friend.id === userCredentials.user_id">

View File

@ -76,10 +76,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

View File

@ -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,

View File

@ -3,7 +3,7 @@ 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 { get } from '@/helpers/settings'
import { edit } from '@/helpers/profile'
import type { InstanceSettingsTabProps, AppSettings, Hooks } from '../../../helpers/types'
@ -25,8 +25,9 @@ const editProfileObject = computed(() => {
hooks?: Hooks
} = {}
// When hooks are not overridden per-instance, we want to clear them
editProfile.hooks = overrideHooks.value ? hooks.value : {}
if (overrideHooks.value) {
editProfile.hooks = hooks.value
}
return editProfile
})

View File

@ -6,9 +6,9 @@ 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 { get_max_memory } from '@/helpers/jre'
import { get } from '@/helpers/settings'
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
import useMemorySlider from '@/composables/useMemorySlider'
const { formatMessage } = useVIntl()
@ -34,7 +34,7 @@ const envVars = ref(
const overrideMemorySettings = ref(!!props.instance.memory)
const memory = ref(props.instance.memory ?? globalSettings.memory)
const { maxMemory, snapPoints } = await useMemorySlider()
const maxMemory = Math.floor((await get_max_memory().catch(handleError)) / 1024)
const editProfileObject = computed(() => {
const editProfile: {
@ -156,8 +156,6 @@ const messages = defineMessages({
: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">

View File

@ -3,7 +3,7 @@ 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 { get } from '@/helpers/settings'
import { edit } from '@/helpers/profile'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'

View File

@ -5,7 +5,7 @@ import {
ShieldIcon,
SettingsIcon,
GaugeIcon,
PaintbrushIcon,
PaintBrushIcon,
GameIcon,
CoffeeIcon,
} from '@modrinth/assets'
@ -22,7 +22,7 @@ import { version as getOsVersion, platform as getOsPlatform } from '@tauri-apps/
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'
import { get, set } from '@/helpers/settings'
const themeStore = useTheming()
@ -41,7 +41,7 @@ const tabs = [
id: 'app.settings.tabs.appearance',
defaultMessage: 'Appearance',
}),
icon: PaintbrushIcon,
icon: PaintBrushIcon,
content: AppearanceSettings,
},
{
@ -116,10 +116,6 @@ function devModeCount() {
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>

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,7 +2,7 @@
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()
@ -41,10 +41,6 @@ const props = defineProps({
type: Boolean,
default: true,
},
markdown: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['proceed'])
@ -84,7 +80,6 @@ function proceed() {
: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,8 +1,8 @@
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { ref } from 'vue'
import { NewModal as 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()
@ -26,16 +26,16 @@ const props = defineProps({
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()
},
})

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,10 +1,9 @@
<script setup lang="ts">
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings.ts'
import { get, set } from '@/helpers/settings'
import { ref, watch } from 'vue'
import { getOS } from '@/helpers/utils'
import type { ColorTheme } from '@/store/theme.ts'
const themeStore = useTheming()
@ -25,13 +24,13 @@ watch(
<ThemeSelector
:update-color-theme="
(theme: ColorTheme) => {
(theme) => {
themeStore.setThemeState(theme)
settings.theme = theme
}
"
:current-theme="settings.theme"
:theme-options="themeStore.getThemeOptions()"
:theme-options="themeStore.themeOptions"
system-theme-color="system"
/>
@ -56,17 +55,9 @@ watch(
/>
</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>
<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" />
@ -89,28 +80,10 @@ watch(
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>

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
import { get, set } from '@/helpers/settings.ts'
import { get, set } from '@/helpers/settings'
import { ref, watch } from 'vue'
import { get_max_memory } from '@/helpers/jre'
import { handleError } from '@/store/notifications'
import { Slider, Toggle } from '@modrinth/ui'
import useMemorySlider from '@/composables/useMemorySlider'
const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
@ -10,7 +11,7 @@ fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).jo
const settings = ref(fetchSettings)
const { maxMemory, snapPoints } = await useMemorySlider()
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
watch(
settings,
@ -106,8 +107,6 @@ watch(
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>

View File

@ -1,16 +1,22 @@
<script setup lang="ts">
import { Toggle } from '@modrinth/ui'
import { ButtonStyled, 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'
import { ref, type Ref, watch } from 'vue'
import { get, set } from '@/helpers/settings'
import { DEFAULT_FEATURE_FLAGS } from '@/store/theme'
type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
const themeStore = useTheming()
const settings = ref(await getSettings())
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
const settings = ref(await get())
const options: Ref<FeatureFlag[]> = ref(Object.keys(DEFAULT_FEATURE_FLAGS) as FeatureFlag[])
function setFeatureFlag(key: string, value: boolean) {
function getStoreValue(key: FeatureFlag) {
return themeStore.featureFlags[key] ?? false
}
function setStoreValue(key: FeatureFlag, value: boolean) {
themeStore.featureFlags[key] = value
settings.value.feature_flags[key] = value
}
@ -18,23 +24,45 @@ function setFeatureFlag(key: string, value: boolean) {
watch(
settings,
async () => {
await setSettings(settings.value)
await set(settings.value)
},
{ deep: true },
)
</script>
<template>
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
<h2 class="m-0 text-lg font-extrabold text-contrast">Feature flags</h2>
<p class="mt-1 mb-0 leading-tight text-secondary">
These are developer tools that are not intended to be used by end users except for debugging
purposes.
</p>
<p class="my-3 font-bold">Do not report bugs or issues if you have any feature flags enabled.</p>
<div
v-for="option in options"
:key="option"
class="mt-2 px-4 py-3 flex items-center justify-between bg-bg rounded-2xl"
>
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
{{ option.replaceAll('_', ' ') }}
<h2 class="m-0 text-base font-bold text-primary capitalize">
{{ option.replace(new RegExp('_', 'g'), ' ') }}
</h2>
<p class="m-0 text-sm text-secondary">Default: {{ DEFAULT_FEATURE_FLAGS[option] }}</p>
</div>
<Toggle
id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
/>
<div class="flex items-center gap-1">
<ButtonStyled type="transparent">
<button
class="text-sm"
:disabled="getStoreValue(option) === DEFAULT_FEATURE_FLAGS[option]"
@click="() => setStoreValue(option, !themeStore.featureFlags[option])"
>
Reset to default
</button>
</ButtonStyled>
<Toggle
id="advanced-rendering"
:model-value="getStoreValue(option)"
@update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
/>
</div>
</div>
</template>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { get, set } from '@/helpers/settings'
import { Toggle } from '@modrinth/ui'
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'

View File

@ -1,7 +1,7 @@
<script setup>
import { Button, Slider } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { get, set } from '@/helpers/settings.js'
import { purge_cache_types } from '@/helpers/cache.js'
import { handleError } from '@/store/notifications.js'
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'

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

@ -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>

View File

@ -0,0 +1,41 @@
import { useRoute } from 'vue-router'
import { ref, computed, type Ref, watch } from 'vue'
import { handleError } from '@/store/notifications'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
import type { GameInstance, InstanceContent } from '@/helpers/types'
export type InstanceContentMap = Record<string, InstanceContent>
export async function useInstanceContext() {
const route = useRoute()
const instance: Ref<GameInstance | undefined> = ref()
const instanceContent: Ref<InstanceContentMap | undefined> = ref()
await loadInstance()
watch(route, () => {
loadInstance()
})
async function loadInstance() {
;[instance.value, instanceContent.value] = await Promise.all([
route.query.i ? getInstance(route.query.i).catch(handleError) : Promise.resolve(),
route.query.i ? getInstanceProjects(route.query.i).catch(handleError) : Promise.resolve(),
])
}
const instanceQueryAppendage = computed(() => {
if (instance.value) {
return `?i=${instance.value.path}`
} else {
return ''
}
})
return {
instance,
instanceContent,
instanceQueryAppendage,
}
}

View File

@ -1,21 +0,0 @@
import { ref, computed } from 'vue'
import { get_max_memory } from '@/helpers/jre.js'
import { handleError } from '@/store/notifications.js'
export default async function () {
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
const snapPoints = computed(() => {
let points = []
let memory = 2048
while (memory <= maxMemory.value) {
points.push(memory)
memory *= 2
}
return points
})
return { maxMemory, snapPoints }
}

View File

@ -1,9 +1,8 @@
import { posthog } from 'posthog-js'
export const initAnalytics = () => {
posthog.init('phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ', {
posthog.init('phc_hm2ihMpTAoE86xIm7XzsCB8RPiTRKivViK5biiHedm', {
persistence: 'localStorage',
api_host: 'https://posthog.modrinth.com',
})
}

View File

@ -62,7 +62,7 @@ export async function process_listener(callback) {
ProfilePayload {
uuid: unique identification of the process in the state (currently identified by path, but that will change)
name: name of the profile
profile_path: relative path toprofile_listener profile (used for path identification)
profile_path: relative path to profile (used for path identification)
path: path to profile (used for opening the profile in the OS file explorer)
event: event type ("Created", "Added", "Edited", "Removed")
}

View File

@ -1,12 +1,12 @@
import { fetch } from '@tauri-apps/plugin-http'
import { ofetch } from 'ofetch'
import { handleError } from '@/store/state.js'
import { getVersion } from '@tauri-apps/api/app'
export const useFetch = async (url, item, isSilent) => {
try {
const version = await getVersion()
return await fetch(url, {
method: 'GET',
return await ofetch(url, {
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
})
} catch (err) {

View File

@ -36,8 +36,8 @@ export async function get_jre(path) {
// Tests JRE version by running 'java -version' on it.
// Returns true if the version is valid, and matches given (after extraction)
export async function test_jre(path, majorVersion) {
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion })
export async function test_jre(path, majorVersion, minorVersion) {
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion, minorVersion })
}
// Automatically installs specified java version

View File

@ -16,7 +16,3 @@ export async function logout() {
export async function get() {
return await invoke('plugin:mr-auth|get')
}
export async function cancelLogin() {
return await invoke('plugin:mr-auth|cancel_modrinth_login')
}

View File

@ -7,13 +7,7 @@ import { invoke } from '@tauri-apps/api/core'
import { create } from './profile'
// Installs pack from a version ID
export async function create_profile_and_install(
projectId,
versionId,
packTitle,
iconUrl,
createInstanceCallback = () => {},
) {
export async function create_profile_and_install(projectId, versionId, packTitle, iconUrl) {
const location = {
type: 'fromVersionId',
project_id: projectId,
@ -30,7 +24,6 @@ export async function create_profile_and_install(
null,
true,
)
createInstanceCallback(profile)
return await invoke('plugin:pack|pack_install', { location, profile })
}

View File

@ -1,446 +0,0 @@
import * as THREE from 'three'
import type { Skin, Cape } from '../skins'
import { get_normalized_skin_texture, determineModelType } from '../skins'
import { reactive } from 'vue'
import {
setupSkinModel,
disposeCaches,
loadTexture,
applyCapeTexture,
createTransparentTexture,
} from '@modrinth/utils'
import { skinPreviewStorage } from '../storage/skin-preview-storage'
import { headStorage } from '../storage/head-storage'
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
export interface RenderResult {
forwards: string
backwards: string
}
export interface RawRenderResult {
forwards: Blob
backwards: Blob
}
class BatchSkinRenderer {
private renderer: THREE.WebGLRenderer | null = null
private scene: THREE.Scene | null = null
private camera: THREE.PerspectiveCamera | null = null
private currentModel: THREE.Group | null = null
private readonly width: number
private readonly height: number
constructor(width: number = 360, height: number = 504) {
this.width = width
this.height = height
}
private initializeRenderer(): void {
if (this.renderer) return
const canvas = document.createElement('canvas')
canvas.width = this.width
canvas.height = this.height
this.renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: true,
alpha: true,
preserveDrawingBuffer: true,
})
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 10.0
this.renderer.setClearColor(0x000000, 0)
this.renderer.setSize(this.width, this.height)
this.scene = new THREE.Scene()
this.camera = new THREE.PerspectiveCamera(20, this.width / this.height, 0.4, 1000)
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = true
directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight)
this.scene.add(directionalLight)
}
public async renderSkin(
textureUrl: string,
modelUrl: string,
capeUrl?: string,
): Promise<RawRenderResult> {
this.initializeRenderer()
this.clearScene()
await this.setupModel(modelUrl, textureUrl, capeUrl)
const headPart = this.currentModel!.getObjectByName('Head')
let lookAtTarget: [number, number, number]
if (headPart) {
const headPosition = new THREE.Vector3()
headPart.getWorldPosition(headPosition)
lookAtTarget = [headPosition.x, headPosition.y - 0.3, headPosition.z]
} else {
throw new Error("Failed to find 'Head' object in model.")
}
const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3]
const backCameraPos: [number, number, number] = [-1.3, 1, -2.5]
const forwards = await this.renderView(frontCameraPos, lookAtTarget)
const backwards = await this.renderView(backCameraPos, lookAtTarget)
return { forwards, backwards }
}
private async renderView(
cameraPosition: [number, number, number],
lookAtPosition: [number, number, number],
): Promise<Blob> {
if (!this.camera || !this.renderer || !this.scene) {
throw new Error('Renderer not initialized')
}
this.camera.position.set(...cameraPosition)
this.camera.lookAt(...lookAtPosition)
this.renderer.render(this.scene, this.camera)
const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9)
const response = await fetch(dataUrl)
return await response.blob()
}
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
if (!this.scene) {
throw new Error('Renderer not initialized')
}
const { model } = await setupSkinModel(modelUrl, textureUrl)
if (capeUrl) {
const capeTexture = await loadTexture(capeUrl)
applyCapeTexture(model, capeTexture)
} else {
const transparentTexture = createTransparentTexture()
applyCapeTexture(model, null, transparentTexture)
}
const group = new THREE.Group()
group.add(model)
group.position.set(0, 0.3, 1.95)
group.scale.set(0.8, 0.8, 0.8)
this.scene.add(group)
this.currentModel = group
}
private clearScene(): void {
if (!this.scene) return
while (this.scene.children.length > 0) {
const child = this.scene.children[0]
this.scene.remove(child)
if (child instanceof THREE.Mesh) {
if (child.geometry) child.geometry.dispose()
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose())
} else {
child.material.dispose()
}
}
}
}
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = true
directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight)
this.scene.add(directionalLight)
this.currentModel = null
}
public dispose(): void {
if (this.renderer) {
this.renderer.dispose()
}
disposeCaches()
}
}
function getModelUrlForVariant(variant: string): string {
switch (variant) {
case 'SLIM':
return SlimPlayerModel
case 'CLASSIC':
case 'UNKNOWN':
default:
return ClassicPlayerModel
}
}
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
export const headBlobUrlMap = reactive(new Map<string, string>())
const DEBUG_MODE = false
let sharedRenderer: BatchSkinRenderer | null = null
function getSharedRenderer(): BatchSkinRenderer {
if (!sharedRenderer) {
sharedRenderer = new BatchSkinRenderer()
}
return sharedRenderer
}
export function disposeSharedRenderer(): void {
if (sharedRenderer) {
sharedRenderer.dispose()
sharedRenderer = null
}
}
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
const validKeys = new Set<string>()
const validHeadKeys = new Set<string>()
for (const skin of skins) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
const headKey = `${skin.texture_key}-head`
validKeys.add(key)
validHeadKeys.add(headKey)
}
try {
await skinPreviewStorage.cleanupInvalidKeys(validKeys)
await headStorage.cleanupInvalidKeys(validHeadKeys)
} catch (error) {
console.warn('Failed to cleanup unused skin previews:', error)
}
}
export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try {
const sourceCanvas = document.createElement('canvas')
const sourceCtx = sourceCanvas.getContext('2d')
if (!sourceCtx) {
throw new Error('Could not get 2D context from source canvas')
}
sourceCanvas.width = img.width
sourceCanvas.height = img.height
sourceCtx.drawImage(img, 0, 0)
const outputCanvas = document.createElement('canvas')
const outputCtx = outputCanvas.getContext('2d')
if (!outputCtx) {
throw new Error('Could not get 2D context from output canvas')
}
outputCanvas.width = size
outputCanvas.height = size
outputCtx.imageSmoothingEnabled = false
const headImageData = sourceCtx.getImageData(8, 8, 8, 8)
const headCanvas = document.createElement('canvas')
const headCtx = headCanvas.getContext('2d')
if (!headCtx) {
throw new Error('Could not get 2D context from head canvas')
}
headCanvas.width = 8
headCanvas.height = 8
headCtx.putImageData(headImageData, 0, 0)
outputCtx.drawImage(headCanvas, 0, 0, 8, 8, 0, 0, size, size)
const hatImageData = sourceCtx.getImageData(40, 8, 8, 8)
const hatCanvas = document.createElement('canvas')
const hatCtx = hatCanvas.getContext('2d')
if (!hatCtx) {
throw new Error('Could not get 2D context from hat canvas')
}
hatCanvas.width = 8
hatCanvas.height = 8
hatCtx.putImageData(hatImageData, 0, 0)
const hatPixels = hatImageData.data
let hasHat = false
for (let i = 3; i < hatPixels.length; i += 4) {
if (hatPixels[i] > 0) {
hasHat = true
break
}
}
if (hasHat) {
outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size)
}
outputCanvas.toBlob(
(blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create blob from canvas'))
}
},
'image/webp',
0.9,
)
} catch (error) {
reject(error)
}
}
img.onerror = () => {
reject(new Error('Failed to load skin texture image'))
}
img.src = skinUrl
})
}
async function generateHeadRender(skin: Skin): Promise<string> {
const headKey = `${skin.texture_key}-head`
if (headBlobUrlMap.has(headKey)) {
if (DEBUG_MODE) {
const url = headBlobUrlMap.get(headKey)!
URL.revokeObjectURL(url)
headBlobUrlMap.delete(headKey)
} else {
return headBlobUrlMap.get(headKey)!
}
}
const skinUrl = await get_normalized_skin_texture(skin)
const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
const headUrl = URL.createObjectURL(headBlob)
headBlobUrlMap.set(headKey, headUrl)
try {
await headStorage.store(headKey, headBlob)
} catch (error) {
console.warn('Failed to store head render in persistent storage:', error)
}
return headUrl
}
export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
return await generateHeadRender(skin)
}
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
try {
const skinKeys = skins.map(
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
)
const headKeys = skins.map((skin) => `${skin.texture_key}-head`)
const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([
skinPreviewStorage.batchRetrieve(skinKeys),
headStorage.batchRetrieve(headKeys),
])
for (let i = 0; i < skins.length; i++) {
const skinKey = skinKeys[i]
const headKey = headKeys[i]
const rawCached = cachedSkinPreviews[skinKey]
if (rawCached) {
const cached: RenderResult = {
forwards: URL.createObjectURL(rawCached.forwards),
backwards: URL.createObjectURL(rawCached.backwards),
}
skinBlobUrlMap.set(skinKey, cached)
}
const cachedHead = cachedHeadPreviews[headKey]
if (cachedHead) {
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
}
}
for (const skin of skins) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
if (skinBlobUrlMap.has(key)) {
if (DEBUG_MODE) {
const result = skinBlobUrlMap.get(key)!
URL.revokeObjectURL(result.forwards)
URL.revokeObjectURL(result.backwards)
skinBlobUrlMap.delete(key)
} else continue
}
const renderer = getSharedRenderer()
let variant = skin.variant
if (variant === 'UNKNOWN') {
try {
variant = await determineModelType(skin.texture)
} catch (error) {
console.error(`Failed to determine model type for skin ${key}:`, error)
variant = 'CLASSIC'
}
}
const modelUrl = getModelUrlForVariant(variant)
const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
const rawRenderResult = await renderer.renderSkin(
await get_normalized_skin_texture(skin),
modelUrl,
cape?.texture,
)
const renderResult: RenderResult = {
forwards: URL.createObjectURL(rawRenderResult.forwards),
backwards: URL.createObjectURL(rawRenderResult.backwards),
}
skinBlobUrlMap.set(key, renderResult)
try {
await skinPreviewStorage.store(key, rawRenderResult)
} catch (error) {
console.warn('Failed to store skin preview in persistent storage:', error)
}
const headKey = `${skin.texture_key}-head`
if (!headBlobUrlMap.has(headKey)) {
await generateHeadRender(skin)
}
}
} finally {
disposeSharedRenderer()
await cleanupUnusedPreviews(skins)
await skinPreviewStorage.debugCalculateStorage()
await headStorage.debugCalculateStorage()
}
}

View File

@ -0,0 +1,43 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
// Settings object
/*
Settings {
"memory": MemorySettings,
"game_resolution": [int int],
"custom_java_args": [String ...],
"custom_env_args" : [(string, string) ... ]>,
"java_globals": Hash of (string, Path),
"default_user": Uuid string (can be null),
"hooks": Hooks,
"max_concurrent_downloads": uint,
"version": u32,
"collapsed_navigation": bool,
}
Memorysettings {
"min": u32, can be null,
"max": u32,
}
*/
// Get full settings object
export async function get() {
return await invoke('plugin:settings|settings_get')
}
// Set full settings object
export async function set(settings) {
return await invoke('plugin:settings|settings_set', { settings })
}
export async function cancel_directory_change() {
return await invoke('plugin:settings|cancel_directory_change')
}

View File

@ -1,79 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
import type { Hooks, MemorySettings, WindowSize } from '@/helpers/types'
// Settings object
/*
Settings {
"memory": MemorySettings,
"game_resolution": [int int],
"custom_java_args": [String ...],
"custom_env_args" : [(string, string) ... ]>,
"java_globals": Hash of (string, Path),
"default_user": Uuid string (can be null),
"hooks": Hooks,
"max_concurrent_downloads": uint,
"version": u32,
"collapsed_navigation": bool,
}
Memorysettings {
"min": u32, can be null,
"max": u32,
}
*/
export type AppSettings = {
max_concurrent_downloads: number
max_concurrent_writes: number
theme: ColorTheme
default_page: 'home' | 'library'
collapsed_navigation: boolean
hide_nametag_skins_page: boolean
advanced_rendering: boolean
native_decorations: boolean
toggle_sidebar: boolean
telemetry: boolean
discord_rpc: boolean
personalized_ads: boolean
onboarded: boolean
extra_launch_args: string[]
custom_env_vars: [string, string][]
memory: MemorySettings
force_fullscreen: boolean
game_resolution: WindowSize
hide_on_process_start: boolean
hooks: Hooks
custom_dir?: string | null
prev_custom_dir?: string | null
migrated: boolean
developer_mode: boolean
feature_flags: Record<FeatureFlag, boolean>
}
// Get full settings object
export async function get() {
return (await invoke('plugin:settings|settings_get')) as AppSettings
}
// Set full settings object
export async function set(settings: AppSettings) {
return await invoke('plugin:settings|settings_set', { settings })
}
export async function cancel_directory_change(): Promise<void> {
return await invoke('plugin:settings|cancel_directory_change')
}

View File

@ -1,164 +0,0 @@
import { invoke } from '@tauri-apps/api/core'
import { handleError } from '@/store/notifications'
import { arrayBufferToBase64 } from '@modrinth/utils'
export interface Cape {
id: string
name: string
texture: string
is_default: boolean
is_equipped: boolean
}
export type SkinModel = 'CLASSIC' | 'SLIM' | 'UNKNOWN'
export type SkinSource = 'default' | 'custom_external' | 'custom'
export interface Skin {
texture_key: string
name?: string
variant: SkinModel
cape_id?: string
texture: string
source: SkinSource
is_equipped: boolean
}
export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[]
export const DEFAULT_MODELS: Record<string, SkinModel> = {
Steve: 'CLASSIC',
Alex: 'SLIM',
Zuri: 'CLASSIC',
Sunny: 'CLASSIC',
Noor: 'SLIM',
Makena: 'SLIM',
Kai: 'CLASSIC',
Efe: 'SLIM',
Ari: 'CLASSIC',
}
export function filterSavedSkins(list: Skin[]) {
const customSkins = list.filter((s) => s.source !== 'default')
fixUnknownSkins(customSkins).catch(handleError)
return customSkins
}
export async function determineModelType(texture: string): Promise<'SLIM' | 'CLASSIC'> {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (!context) {
return reject(new Error('Failed to create canvas rendering context.'))
}
const image = new Image()
image.crossOrigin = 'anonymous'
image.src = texture
image.onload = () => {
canvas.width = image.width
canvas.height = image.height
context.drawImage(image, 0, 0)
const armX = 54
const armY = 20
const armWidth = 2
const armHeight = 12
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) {
if (imageData[alphaIndex] !== 0) {
resolve('CLASSIC')
return
}
}
canvas.remove()
resolve('SLIM')
}
image.onerror = () => {
canvas.remove()
reject(new Error('Failed to load the image.'))
}
})
}
export async function fixUnknownSkins(list: Skin[]) {
const unknownSkins = list.filter((s) => s.variant === 'UNKNOWN')
for (const unknownSkin of unknownSkins) {
unknownSkin.variant = await determineModelType(unknownSkin.texture)
}
}
export function filterDefaultSkins(list: Skin[]) {
return list
.filter(
(s) =>
s.source === 'default' &&
(!s.name || !(s.name in DEFAULT_MODELS) || s.variant === DEFAULT_MODELS[s.name]),
)
.sort((a, b) => {
const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1
const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex)
})
}
export async function get_available_capes(): Promise<Cape[]> {
return invoke('plugin:minecraft-skins|get_available_capes', {})
}
export async function get_available_skins(): Promise<Skin[]> {
return invoke('plugin:minecraft-skins|get_available_skins', {})
}
export async function add_and_equip_custom_skin(
textureBlob: Uint8Array,
variant: SkinModel,
capeOverride?: Cape,
): Promise<void> {
await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
textureBlob,
variant,
capeOverride,
})
}
export async function set_default_cape(cape?: Cape): Promise<void> {
await invoke('plugin:minecraft-skins|set_default_cape', {
cape,
})
}
export async function equip_skin(skin: Skin): Promise<void> {
await invoke('plugin:minecraft-skins|equip_skin', {
skin,
})
}
export async function remove_custom_skin(skin: Skin): Promise<void> {
await invoke('plugin:minecraft-skins|remove_custom_skin', {
skin,
})
}
export async function get_normalized_skin_texture(skin: Skin): Promise<string> {
const data = await normalize_skin_texture(skin.texture)
const base64 = arrayBufferToBase64(data)
return `data:image/png;base64,${base64}`
}
export async function normalize_skin_texture(texture: Uint8Array | string): Promise<Uint8Array> {
return await invoke('plugin:minecraft-skins|normalize_skin_texture', { texture })
}
export async function unequip_skin(): Promise<void> {
await invoke('plugin:minecraft-skins|unequip_skin')
}
export async function get_dragged_skin_data(path: string): Promise<Uint8Array> {
const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
return new Uint8Array(data)
}

View File

@ -1,229 +0,0 @@
interface StoredHead {
blob: Blob
timestamp: number
}
export class HeadStorage {
private dbName = 'head-storage'
private version = 1
private db: IDBDatabase | null = null
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve()
}
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains('heads')) {
db.createObjectStore('heads')
}
}
})
}
async store(key: string, blob: Blob): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
const storedHead: StoredHead = {
blob,
timestamp: Date.now(),
}
return new Promise((resolve, reject) => {
const request = store.put(storedHead, key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
async retrieve(key: string): Promise<string | null> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
return new Promise((resolve, reject) => {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredHead | undefined
if (!result) {
resolve(null)
return
}
const url = URL.createObjectURL(result.blob)
resolve(url)
}
request.onerror = () => reject(request.error)
})
}
async batchRetrieve(keys: string[]): Promise<Record<string, Blob | null>> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
const results: Record<string, Blob | null> = {}
return new Promise((resolve, _reject) => {
let completedRequests = 0
if (keys.length === 0) {
resolve(results)
return
}
for (const key of keys) {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredHead | undefined
if (result) {
results[key] = result.blob
} else {
results[key] = null
}
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
request.onerror = () => {
results[key] = null
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
}
})
}
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
let deletedCount = 0
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
if (!validKeys.has(key)) {
const deleteRequest = cursor.delete()
deleteRequest.onsuccess = () => {
deletedCount++
}
deleteRequest.onerror = () => {
console.warn('Failed to delete invalid head entry:', key)
}
}
cursor.continue()
} else {
resolve(deletedCount)
}
}
request.onerror = () => reject(request.error)
})
}
async debugCalculateStorage(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
let totalSize = 0
let count = 0
const entries: Array<{ key: string; size: number }> = []
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
const value = cursor.value as StoredHead
const entrySize = value.blob.size
totalSize += entrySize
count++
entries.push({
key,
size: entrySize,
})
cursor.continue()
} else {
console.group('🗄️ Head Storage Debug Info')
console.log(`Total entries: ${count}`)
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
console.log(
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
)
if (entries.length > 0) {
const sortedEntries = entries.sort((a, b) => b.size - a.size)
console.log(
'Largest entry:',
sortedEntries[0].key,
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
)
console.log(
'Smallest entry:',
sortedEntries[sortedEntries.length - 1].key,
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
)
}
console.groupEnd()
resolve()
}
}
request.onerror = () => reject(request.error)
})
}
async clearAll(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
return new Promise((resolve, reject) => {
const request = store.clear()
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
}
export const headStorage = new HeadStorage()

View File

@ -1,218 +0,0 @@
import type { RawRenderResult } from '../rendering/batch-skin-renderer'
interface StoredPreview {
forwards: Blob
backwards: Blob
timestamp: number
}
export class SkinPreviewStorage {
private dbName = 'skin-previews'
private version = 1
private db: IDBDatabase | null = null
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve()
}
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains('previews')) {
db.createObjectStore('previews')
}
}
})
}
async store(key: string, result: RawRenderResult): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readwrite')
const store = transaction.objectStore('previews')
const storedPreview: StoredPreview = {
forwards: result.forwards,
backwards: result.backwards,
timestamp: Date.now(),
}
return new Promise((resolve, reject) => {
const request = store.put(storedPreview, key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
async retrieve(key: string): Promise<RawRenderResult | null> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
return new Promise((resolve, reject) => {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredPreview | undefined
if (!result) {
resolve(null)
return
}
resolve({ forwards: result.forwards, backwards: result.backwards })
}
request.onerror = () => reject(request.error)
})
}
async batchRetrieve(keys: string[]): Promise<Record<string, RawRenderResult | null>> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
const results: Record<string, RawRenderResult | null> = {}
return new Promise((resolve, _reject) => {
let completedRequests = 0
if (keys.length === 0) {
resolve(results)
return
}
for (const key of keys) {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredPreview | undefined
if (result) {
results[key] = { forwards: result.forwards, backwards: result.backwards }
} else {
results[key] = null
}
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
request.onerror = () => {
results[key] = null
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
}
})
}
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readwrite')
const store = transaction.objectStore('previews')
let deletedCount = 0
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
if (!validKeys.has(key)) {
const deleteRequest = cursor.delete()
deleteRequest.onsuccess = () => {
deletedCount++
}
deleteRequest.onerror = () => {
console.warn('Failed to delete invalid entry:', key)
}
}
cursor.continue()
} else {
resolve(deletedCount)
}
}
request.onerror = () => reject(request.error)
})
}
async debugCalculateStorage(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
let totalSize = 0
let count = 0
const entries: Array<{ key: string; size: number }> = []
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
const value = cursor.value as StoredPreview
const entrySize = value.forwards.size + value.backwards.size
totalSize += entrySize
count++
entries.push({
key,
size: entrySize,
})
cursor.continue()
} else {
console.group('🗄️ Skin Preview Storage Debug Info')
console.log(`Total entries: ${count}`)
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
console.log(
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
)
if (entries.length > 0) {
const sortedEntries = entries.sort((a, b) => b.size - a.size)
console.log(
'Largest entry:',
sortedEntries[0].key,
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
)
console.log(
'Smallest entry:',
sortedEntries[sortedEntries.length - 1].key,
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
)
}
console.groupEnd()
resolve()
}
}
request.onerror = () => reject(request.error)
})
}
}
export const skinPreviewStorage = new SkinPreviewStorage()

View File

@ -32,6 +32,18 @@ type GameInstance = {
hooks: Hooks
}
type InstanceContent = {
hash: string
file_name: string
size: number
metadata?: {
project_id: ModrinthId
version_id: ModrinthId
}
update_version_id: string
project_type: 'mod' | 'resourcepack' | 'datapack' | 'shaderpack'
}
type InstallStage =
| 'installed'
| 'minecraft_installing'
@ -48,32 +60,6 @@ type LinkedData = {
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
type ContentFile = {
hash: string
file_name: string
size: number
metadata?: FileMetadata
update_version_id?: string
project_type: ContentFileProjectType
}
type FileMetadata = {
project_id: string
version_id: string
}
type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack'
type CacheBehaviour =
// Serve expired data. If fetch fails / launcher is offline, errors are ignored
| 'stale_while_revalidate_skip_offline'
// Serve expired data, revalidate in background
| 'stale_while_revalidate'
// Must revalidate if data is expired
| 'must_revalidate'
// Ignore cache- always fetch updated data from origin
| 'bypass'
type MemorySettings = {
maximum: number
}
@ -114,7 +100,6 @@ type AppSettings = {
collapsed_navigation: boolean
advanced_rendering: boolean
native_decorations: boolean
worlds_in_home: boolean
telemetry: boolean
discord_rpc: boolean

View File

@ -37,13 +37,6 @@ export async function restartApp() {
return await invoke('restart_app')
}
/**
* @deprecated This method is no longer needed, and just returns its parameter
*/
export function sanitizePotentialFileUrl(url) {
return url
}
export const releaseColor = (releaseType) => {
switch (releaseType) {
case 'release':
@ -56,7 +49,3 @@ export const releaseColor = (releaseType) => {
return ''
}
}
export async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
}

View File

@ -1,350 +0,0 @@
import { invoke } from '@tauri-apps/api/core'
import { get_full_path } from '@/helpers/profile'
import { openPath } from '@/helpers/utils'
import { autoToHTML } from '@geometrically/minecraft-motd-parser'
import dayjs from 'dayjs'
import type { GameVersion } from '@modrinth/ui'
type BaseWorld = {
name: string
last_played?: string
icon?: string
display_status: DisplayStatus
type: WorldType
}
export type WorldType = 'singleplayer' | 'server'
export type DisplayStatus = 'normal' | 'hidden' | 'favorite'
export type SingleplayerWorld = BaseWorld & {
type: 'singleplayer'
path: string
game_mode: SingleplayerGameMode
hardcore: boolean
locked: boolean
}
export type ServerWorld = BaseWorld & {
type: 'server'
index: number
address: string
pack_status: ServerPackStatus
}
export type World = SingleplayerWorld | ServerWorld
export type WorldWithProfile = {
profile: string
} & World
export type SingleplayerGameMode = 'survival' | 'creative' | 'adventure' | 'spectator'
export type ServerPackStatus = 'enabled' | 'disabled' | 'prompt'
export type ServerStatus = {
// https://minecraft.wiki/w/Text_component_format
description?: string | Chat
players?: {
max: number
online: number
sample: { name: string; id: string }[]
}
version?: {
name: string
protocol: number
legacy: boolean
}
favicon?: string
enforces_secure_chat: boolean
ping?: number
}
export interface Chat {
text: string
bold: boolean
italic: boolean
underlined: boolean
strikethrough: boolean
obfuscated: boolean
color?: string
extra: Chat[]
}
export type ServerData = {
refreshing: boolean
lastSuccessfulRefresh?: number
status?: ServerStatus
rawMotd?: string | Chat
renderedMotd?: string
}
export type ProtocolVersion = {
version: number
legacy: boolean
}
export async function get_recent_worlds(
limit: number,
displayStatuses?: DisplayStatus[],
): Promise<WorldWithProfile[]> {
return await invoke('plugin:worlds|get_recent_worlds', { limit, displayStatuses })
}
export async function get_profile_worlds(path: string): Promise<World[]> {
return await invoke('plugin:worlds|get_profile_worlds', { path })
}
export async function get_singleplayer_world(
instance: string,
world: string,
): Promise<SingleplayerWorld> {
return await invoke('plugin:worlds|get_singleplayer_world', { instance, world })
}
export async function set_world_display_status(
instance: string,
worldType: WorldType,
worldId: string,
displayStatus: DisplayStatus,
): Promise<void> {
return await invoke('plugin:worlds|set_world_display_status', {
instance,
worldType,
worldId,
displayStatus,
})
}
export async function rename_world(
instance: string,
world: string,
newName: string,
): Promise<void> {
return await invoke('plugin:worlds|rename_world', { instance, world, newName })
}
export async function reset_world_icon(instance: string, world: string): Promise<void> {
return await invoke('plugin:worlds|reset_world_icon', { instance, world })
}
export async function backup_world(instance: string, world: string): Promise<number> {
return await invoke('plugin:worlds|backup_world', { instance, world })
}
export async function delete_world(instance: string, world: string): Promise<void> {
return await invoke('plugin:worlds|delete_world', { instance, world })
}
export async function add_server_to_profile(
path: string,
name: string,
address: string,
packStatus: ServerPackStatus,
): Promise<number> {
return await invoke('plugin:worlds|add_server_to_profile', { path, name, address, packStatus })
}
export async function edit_server_in_profile(
path: string,
index: number,
name: string,
address: string,
packStatus: ServerPackStatus,
): Promise<void> {
return await invoke('plugin:worlds|edit_server_in_profile', {
path,
index,
name,
address,
packStatus,
})
}
export async function remove_server_from_profile(path: string, index: number): Promise<void> {
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
}
export async function get_profile_protocol_version(path: string): Promise<ProtocolVersion | null> {
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
}
export async function get_server_status(
address: string,
protocolVersion: ProtocolVersion | null = null,
): Promise<ServerStatus> {
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
}
export async function start_join_singleplayer_world(path: string, world: string): Promise<unknown> {
return await invoke('plugin:worlds|start_join_singleplayer_world', { path, world })
}
export async function start_join_server(path: string, address: string): Promise<unknown> {
return await invoke('plugin:worlds|start_join_server', { path, address })
}
export async function showWorldInFolder(instancePath: string, worldPath: string) {
const fullPath = await get_full_path(instancePath)
return await openPath(fullPath + '/saves/' + worldPath)
}
export function getWorldIdentifier(world: World) {
return world.type === 'singleplayer' ? world.path : world.address
}
export function sortWorlds(worlds: World[]) {
worlds.sort((a, b) => {
if (!a.last_played) {
return 1
}
if (!b.last_played) {
return -1
}
return dayjs(b.last_played).diff(dayjs(a.last_played))
})
}
export function isSingleplayerWorld(world: World): world is SingleplayerWorld {
return world.type === 'singleplayer'
}
export function isServerWorld(world: World): world is ServerWorld {
return world.type === 'server'
}
export async function refreshServerData(
serverData: ServerData,
protocolVersion: ProtocolVersion | null,
address: string,
): Promise<void> {
const refreshTime = Date.now()
serverData.refreshing = true
await get_server_status(address, protocolVersion)
.then((status) => {
if (serverData.lastSuccessfulRefresh && serverData.lastSuccessfulRefresh > refreshTime) {
// Don't update if there was a more recent successful refresh
return
}
serverData.lastSuccessfulRefresh = Date.now()
serverData.status = status
if (status.description) {
serverData.rawMotd = status.description
serverData.renderedMotd = autoToHTML(status.description)
}
})
.finally(() => {
serverData.refreshing = false
})
.catch((err) => {
console.error(`Refreshing addr ${address}`, protocolVersion, err)
if (!protocolVersion?.legacy) {
refreshServerData(serverData, { version: 74, legacy: true }, address)
}
})
}
export function refreshServers(
worlds: World[],
serverData: Record<string, ServerData>,
protocolVersion: ProtocolVersion | null,
) {
const servers = worlds.filter(isServerWorld)
servers.forEach((server) => {
if (!serverData[server.address]) {
serverData[server.address] = {
refreshing: true,
}
} else {
serverData[server.address].refreshing = true
}
})
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
Object.keys(serverData).forEach((address) =>
refreshServerData(serverData[address], protocolVersion, address),
)
}
export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) {
const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath)
const newWorld = await get_singleplayer_world(instancePath, worldPath)
if (index !== -1) {
worlds[index] = newWorld
} else {
console.info(`Adding new world at path: ${worldPath}.`)
worlds.push(newWorld)
}
sortWorlds(worlds)
}
export async function handleDefaultProfileUpdateEvent(
worlds: World[],
instancePath: string,
e: ProfileEvent,
) {
if (e.event === 'world_updated') {
await refreshWorld(worlds, instancePath, e.world)
}
if (e.event === 'server_joined') {
const world = worlds.find(
(w) =>
w.type === 'server' &&
(w.address === `${e.host}:${e.port}` || (e.port == 25565 && w.address == e.host)),
)
if (world) {
world.last_played = e.timestamp
sortWorlds(worlds)
} else {
console.error(`Could not find world for server join event: ${e.host}:${e.port}`)
}
}
}
export async function refreshWorlds(instancePath: string): Promise<World[]> {
const worlds = await get_profile_worlds(instancePath).catch((err) => {
console.error(`Error refreshing worlds for instance: ${instancePath}`, err)
})
if (worlds) {
sortWorlds(worlds)
}
return worlds ?? []
}
export function hasServerQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
if (!gameVersions.length) {
return true
}
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
const targetIndex = gameVersions.findIndex((v) => v.version === 'a1.0.5_01')
return versionIndex === -1 || targetIndex === -1 || versionIndex <= targetIndex
}
export function hasWorldQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
if (!gameVersions.length) {
return false
}
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
const targetIndex = gameVersions.findIndex((v) => v.version === '23w14a')
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
}
export type ProfileEvent = { profile_path_id: string } & (
| {
event: 'servers_updated'
}
| {
event: 'world_updated'
world: string
}
| {
event: 'server_joined'
host: string
port: number
timestamp: string
}
)

View File

@ -20,60 +20,12 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"instance.add-server.add-and-play": {
"message": "Add and play"
},
"instance.add-server.add-server": {
"message": "Add server"
},
"instance.add-server.resource-pack.disabled": {
"message": "Disabled"
},
"instance.add-server.resource-pack.enabled": {
"message": "Enabled"
},
"instance.add-server.resource-pack.prompt": {
"message": "Prompt"
},
"instance.add-server.title": {
"message": "Add a server"
},
"instance.edit-server.title": {
"message": "Edit server"
},
"instance.edit-world.hide-from-home": {
"message": "Hide from the Home page"
},
"instance.edit-world.name": {
"message": "Name"
},
"instance.edit-world.placeholder-name": {
"message": "Minecraft World"
},
"instance.edit-world.reset-icon": {
"message": "Reset icon"
},
"instance.edit-world.title": {
"message": "Edit world"
},
"instance.filter.disabled": {
"message": "Disabled projects"
},
"instance.filter.updates-available": {
"message": "Updates available"
},
"instance.server-modal.address": {
"message": "Address"
},
"instance.server-modal.name": {
"message": "Name"
},
"instance.server-modal.placeholder-name": {
"message": "Minecraft Server"
},
"instance.server-modal.resource-pack": {
"message": "Resource pack"
},
"instance.settings.tabs.general": {
"message": "General"
},
@ -356,53 +308,17 @@
"instance.settings.title": {
"message": "Settings"
},
"instance.worlds.a_minecraft_server": {
"message": "A Minecraft Server"
"project.card.actions.installed.tooltip": {
"message": "This project is already installed"
},
"instance.worlds.cant_connect": {
"message": "Can't connect to server"
"project.card.actions.installing.tooltip": {
"message": "This project is being installed"
},
"instance.worlds.copy_address": {
"message": "Copy address"
"project.card.actions.view-gallery": {
"message": "View gallery"
},
"instance.worlds.dont_show_on_home": {
"message": "Don't show on Home"
},
"instance.worlds.filter.available": {
"message": "Available"
},
"instance.worlds.game_already_open": {
"message": "Instance is already open"
},
"instance.worlds.hardcore": {
"message": "Hardcore mode"
},
"instance.worlds.incompatible_server": {
"message": "Server is incompatible"
},
"instance.worlds.no_contact": {
"message": "Server couldn't be contacted"
},
"instance.worlds.no_server_quick_play": {
"message": "You can only jump straight into servers on Minecraft Alpha 1.0.5+"
},
"instance.worlds.no_singleplayer_quick_play": {
"message": "You can only jump straight into singleplayer worlds on Minecraft 1.20+"
},
"instance.worlds.play_instance": {
"message": "Play instance"
},
"instance.worlds.type.server": {
"message": "Server"
},
"instance.worlds.type.singleplayer": {
"message": "Singleplayer"
},
"instance.worlds.view_instance": {
"message": "View instance"
},
"instance.worlds.world_in_use": {
"message": "World is in use"
"project.card.actions.view-versions": {
"message": "View versions"
},
"search.filter.locked.instance": {
"message": "Provided by the instance"

View File

@ -6,7 +6,6 @@ import FloatingVue from 'floating-vue'
import 'floating-vue/dist/style.css'
import { createPlugin } from '@vintl/vintl/plugin'
import * as Sentry from '@sentry/vue'
import { VueScanPlugin } from '@taijased/vue-render-tracker'
const VIntlPlugin = createPlugin({
controllerOpts: {
@ -25,13 +24,6 @@ const VIntlPlugin = createPlugin({
injectInto: [],
})
const vueScan = new VueScanPlugin({
enabled: false, // Enable or disable the tracker
showOverlay: true, // Show overlay to visualize renders
log: false, // Log render events to the console
playSound: false, // Play sound on each render
})
const pinia = createPinia()
let app = createApp(App)
@ -43,7 +35,6 @@ Sentry.init({
tracesSampleRate: 0.1,
})
app.use(vueScan)
app.use(router)
app.use(pinia)
app.use(FloatingVue, {

View File

@ -2,7 +2,14 @@
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import type { Ref } from 'vue'
import { SearchIcon, XIcon, ClipboardCopyIcon, GlobeIcon, ExternalIcon } from '@modrinth/assets'
import type { Category, GameVersion, Platform, ProjectType, SortType, Tags } from '@modrinth/ui'
import type {
CategoryTag,
GameVersionTag,
PlatformTag,
ProjectType,
SortType,
Tags,
} from '@modrinth/ui'
import {
SearchFilterControl,
SearchSidebarFilter,
@ -19,14 +26,14 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile.js'
import { get_search_results } from '@/helpers/cache.js'
import NavTabs from '@/components/ui/NavTabs.vue'
import type Instance from '@/components/ui/Instance.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import { defineMessages, useVIntl } from '@vintl/vintl'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
import { useInstanceContext } from '@/composables/instance-context.ts'
import type { SearchResult } from '@modrinth/utils'
const { formatMessage } = useVIntl()
@ -38,62 +45,45 @@ const projectTypes = computed(() => {
})
const [categories, loaders, availableGameVersions] = await Promise.all([
get_categories().catch(handleError).then(ref),
get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref),
get_categories()
.catch(handleError)
.then((x: CategoryTag[]) => ref(x)),
get_loaders()
.catch(handleError)
.then((x: PlatformTag[]) => ref(x)),
get_game_versions()
.catch(handleError)
.then((x: GameVersionTag[]) => ref(x)),
])
const tags: Ref<Tags> = computed(() => ({
gameVersions: availableGameVersions.value as GameVersion[],
loaders: loaders.value as Platform[],
categories: categories.value as Category[],
gameVersions: availableGameVersions.value as GameVersionTag[],
loaders: loaders.value as PlatformTag[],
categories: categories.value as CategoryTag[],
}))
type Instance = {
game_version: string
loader: string
path: string
install_stage: string
icon_path?: string
name: string
}
type InstanceProject = {
metadata: {
project_id: string
}
}
const instance: Ref<Instance | null> = ref(null)
const instanceProjects: Ref<InstanceProject[] | null> = ref(null)
const instanceHideInstalled = ref(false)
const newlyInstalled = ref([])
const newlyInstalled: Ref<string[]> = ref([])
const { instance, instanceContent } = await useInstanceContext()
const PERSISTENT_QUERY_PARAMS = ['i', 'ai']
await updateInstanceContext()
await checkHideInstalledQuery()
watch(route, () => {
updateInstanceContext()
watch(instance, () => {
checkHideInstalledQuery()
})
async function updateInstanceContext() {
if (route.query.i) {
;[instance.value, instanceProjects.value] = await Promise.all([
getInstance(route.query.i).catch(handleError),
getInstanceProjects(route.query.i).catch(handleError),
])
newlyInstalled.value = []
}
async function checkHideInstalledQuery() {
if (route.query.ai && !(projectTypes.value.length === 1 && projectTypes.value[0] === 'modpack')) {
instanceHideInstalled.value = route.query.ai === 'true'
}
if (instance.value && instance.value.path !== route.query.i && route.path.startsWith('/browse')) {
instance.value = null
instanceHideInstalled.value = false
}
// if (instance.value && instance.value.path !== route.query.i && route.path.startsWith('/browse')) {
// instance.value = undefined
// instanceHideInstalled.value = false
// }
}
const instanceFilters = computed(() => {
@ -119,10 +109,10 @@ const instanceFilters = computed(() => {
})
}
if (instanceHideInstalled.value && instanceProjects.value) {
const installedMods = Object.values(instanceProjects.value)
if (instanceHideInstalled.value && instanceContent.value) {
const installedMods: string[] = Object.values(instanceContent.value)
.filter((x) => x.metadata)
.map((x) => x.metadata.project_id)
.map((x) => x.metadata!.project_id)
installedMods.push(...newlyInstalled.value)
@ -173,23 +163,27 @@ breadcrumbs.setContext({ name: 'Discover content', link: route.path, query: rout
const loading = ref(true)
const projectType = ref(route.params.projectType)
const projectType: Ref<ProjectType | undefined> = ref(
typeof route.params.projectType === 'string'
? (route.params.projectType as ProjectType)
: undefined,
)
watch(projectType, () => {
loading.value = true
})
type SearchResult = {
project_id: string
type ExtendedSearchResult = SearchResult & {
installed?: boolean
}
type SearchResults = {
total_hits: number
limit: number
hits: SearchResult[]
hits: ExtendedSearchResult[]
}
const results: Ref<SearchResults | null> = shallowRef(null)
const results: Ref<SearchResults | undefined> = shallowRef()
const pageCount = computed(() =>
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
)
@ -200,7 +194,7 @@ watch(requestParams, () => {
})
async function refreshSearch() {
let rawResults = await get_search_results(requestParams.value)
let rawResults = (await get_search_results(requestParams.value)) as { result: SearchResults }
if (!rawResults) {
rawResults = {
result: {
@ -211,16 +205,17 @@ async function refreshSearch() {
}
}
if (instance.value) {
for (const val of rawResults.result.hits) {
val.installed =
newlyInstalled.value.includes(val.project_id) ||
Object.values(instanceProjects.value).some(
(x) => x.metadata && x.metadata.project_id === val.project_id,
)
}
rawResults.result.hits.map((x) => ({
...x,
installed:
newlyInstalled.value.includes(x.project_id) ||
(instanceContent.value &&
Object.values(instanceContent.value).some(
(content) => content.metadata && content.metadata.project_id === x.project_id,
)),
}))
}
results.value = rawResults.result
currentPage.value = 1
const persistentParams: LocationQuery = {}
@ -266,16 +261,15 @@ async function onSearchChangeToTop() {
function clearSearch() {
query.value = ''
currentPage.value = 1
}
watch(
() => route.params.projectType,
async (newType) => {
// Check if the newType is not the same as the current value
if (!newType || newType === projectType.value) return
if (!newType || newType === projectType.value || typeof newType !== 'string') return
projectType.value = newType
projectType.value = newType as ProjectType
currentSortType.value = { display: 'Relevance', name: 'relevance' }
query.value = ''
@ -289,7 +283,7 @@ const selectableProjectTypes = computed(() => {
if (instance.value) {
if (
availableGameVersions.value.findIndex((x) => x.version === instance.value.game_version) <=
availableGameVersions.value.findIndex((x) => x.version === instance.value?.game_version) <=
availableGameVersions.value.findIndex((x) => x.version === '1.13')
) {
dataPacks = true
@ -355,9 +349,10 @@ const messages = defineMessages({
},
})
const options = ref(null)
const handleRightClick = (event, result) => {
options.value.showMenu(event, result, [
const options: Ref<InstanceType<typeof ContextMenu> | null> = ref(null)
const handleRightClick = (event: MouseEvent, result: ExtendedSearchResult) => {
options.value?.showMenu(event, result, [
{
name: 'open_link',
},
@ -366,7 +361,7 @@ const handleRightClick = (event, result) => {
},
])
}
const handleOptionsClick = (args) => {
const handleOptionsClick = (args: { item: ExtendedSearchResult; option: string }) => {
switch (args.option) {
case 'open_link':
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
@ -479,33 +474,26 @@ await refreshSearch()
<section v-if="loading" class="offline">
<LoadingIndicator />
</section>
<section v-else-if="offline && results.total_hits === 0" class="offline">
<section v-else-if="offline && (!results || results.total_hits === 0)" class="offline">
You are currently offline. Connect to the internet to browse Modrinth!
</section>
<section v-else class="project-list display-mode--list instance-results" role="list">
<section
v-else-if="results"
class="project-list display-mode--list instance-results"
role="list"
>
<SearchCard
v-for="result in results.hits"
:key="result?.project_id"
:project="result"
:instance="instance"
:categories="[
...categories.filter(
(cat) =>
result?.display_categories.includes(cat.name) && cat.project_type === projectType,
),
...loaders.filter(
(loader) =>
result?.display_categories.includes(loader.name) &&
loader.supported_project_types?.includes(projectType),
),
]"
:installed="result.installed || newlyInstalled.includes(result.project_id)"
@install="
(id) => {
newlyInstalled.push(id)
}
"
@contextmenu.prevent.stop="(event) => handleRightClick(event, result)"
@contextmenu.prevent.stop="(event: MouseEvent) => handleRightClick(event, result)"
/>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>

View File

@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup>
import { ref, onUnmounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import RowDisplay from '@/components/RowDisplay.vue'
@ -8,33 +8,19 @@ import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js'
import dayjs from 'dayjs'
import { get_search_results } from '@/helpers/cache.js'
import type { SearchResult } from '@modrinth/utils'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
import type { GameInstance } from '@/helpers/types'
const featuredModpacks = ref({})
const featuredMods = ref({})
const filter = ref('')
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
breadcrumbs.setRootContext({ name: 'Home', link: route.path })
const instances = ref<GameInstance[]>([])
const recentInstances = ref([])
const featuredModpacks = ref<SearchResult[]>([])
const featuredMods = ref<SearchResult[]>([])
const installedModpacksFilter = ref('')
const recentInstances = computed(() =>
instances.value
.filter((x) => x.last_played)
.slice()
.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played))),
)
const hasFeaturedProjects = computed(
() => (featuredModpacks.value?.length ?? 0) + (featuredMods.value?.length ?? 0) > 0,
)
const offline = ref<boolean>(!navigator.onLine)
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
offline.value = true
})
@ -42,21 +28,34 @@ window.addEventListener('online', () => {
offline.value = false
})
async function fetchInstances() {
instances.value = await list().catch(handleError)
const getInstances = async () => {
const profiles = await list().catch(handleError)
recentInstances.value = profiles
.filter((x) => x.last_played)
.sort((a, b) => {
const dateA = dayjs(a.last_played)
const dateB = dayjs(b.last_played)
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
return dateB - dateA
})
const filters = []
for (const instance of instances.value) {
for (const instance of profiles) {
if (instance.linked_data && instance.linked_data.project_id) {
filters.push(`NOT"project_id"="${instance.linked_data.project_id}"`)
}
}
installedModpacksFilter.value = filters.join(' AND ')
filter.value = filters.join(' AND ')
}
async function fetchFeaturedModpacks() {
const getFeaturedModpacks = async () => {
const response = await get_search_results(
`?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${installedModpacksFilter.value}`,
`?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${filter.value}`,
)
if (response) {
@ -65,8 +64,7 @@ async function fetchFeaturedModpacks() {
featuredModpacks.value = []
}
}
async function fetchFeaturedMods() {
const getFeaturedMods = async () => {
const response = await get_search_results('?facets=[["project_type:mod"]]&limit=10&index=follows')
if (response) {
@ -76,22 +74,26 @@ async function fetchFeaturedMods() {
}
}
async function refreshFeaturedProjects() {
await Promise.all([fetchFeaturedModpacks(), fetchFeaturedMods()])
}
await getInstances()
await fetchInstances()
await refreshFeaturedProjects()
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
const unlistenProfile = await profile_listener(
async (e: { event: string; profile_path_id: string }) => {
await fetchInstances()
const unlistenProfile = await profile_listener(async (e) => {
await getInstances()
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
await refreshFeaturedProjects()
}
},
)
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
}
})
// computed sums of recentInstances, featuredModpacks, featuredMods, treating them as arrays if they are not
const total = computed(() => {
return (
(recentInstances.value?.length ?? 0) +
(featuredModpacks.value?.length ?? 0) +
(featuredMods.value?.length ?? 0)
)
})
onUnmounted(() => {
unlistenProfile()
@ -100,12 +102,19 @@ onUnmounted(() => {
<template>
<div class="p-6 flex flex-col gap-2">
<h1 v-if="recentInstances?.length > 0" class="m-0 text-2xl font-extrabold">Welcome back!</h1>
<h1 v-else class="m-0 text-2xl font-extrabold">Welcome to Modrinth App!</h1>
<RecentWorldsList :recent-instances="recentInstances" />
<h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
<h1 v-else class="m-0 text-2xl">Welcome to Modrinth App!</h1>
<RowDisplay
v-if="hasFeaturedProjects"
v-if="total > 0"
:instances="[
{
label: 'Recently played',
route: '/library',
instances: recentInstances,
instance: true,
downloaded: true,
compact: true,
},
{
label: 'Discover a modpack',
route: '/browse/modpack',

View File

@ -1,521 +0,0 @@
<script setup lang="ts">
import {
EditIcon,
ExcitedRinthbot,
LogInIcon,
PlusIcon,
SpinnerIcon,
TrashIcon,
UpdatedIcon,
} from '@modrinth/assets'
import {
Button,
ButtonStyled,
ConfirmModal,
SkinButton,
SkinLikeTextButton,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { computedAsync } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
import { handleError, useNotifications } from '@/store/notifications'
import type { Cape, Skin } from '@/helpers/skins.ts'
import {
normalize_skin_texture,
equip_skin,
filterDefaultSkins,
filterSavedSkins,
get_available_capes,
get_available_skins,
get_normalized_skin_texture,
remove_custom_skin,
set_default_cape,
} from '@/helpers/skins.ts'
import { get as getSettings } from '@/helpers/settings.ts'
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import { handleSevereError } from '@/store/error'
import { trackEvent } from '@/helpers/analytics'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import { arrayBufferToBase64 } from '@modrinth/utils'
const editSkinModal = useTemplateRef('editSkinModal')
const selectCapeModal = useTemplateRef('selectCapeModal')
const uploadSkinModal = useTemplateRef('uploadSkinModal')
const notifications = useNotifications()
const settings = ref(await getSettings())
const skins = ref<Skin[]>([])
const capes = ref<Cape[]>([])
const accountsCard = inject('accountsCard') as Ref<typeof AccountsCard>
const currentUser = ref(undefined)
const currentUserId = ref<string | undefined>(undefined)
const username = computed(() => currentUser.value?.profile?.name ?? undefined)
const selectedSkin = ref<Skin | null>(null)
const defaultCape = ref<Cape>()
const originalSelectedSkin = ref<Skin | null>(null)
const originalDefaultCape = ref<Cape>()
const savedSkins = computed(() => filterSavedSkins(skins.value))
const defaultSkins = computed(() => filterDefaultSkins(skins.value))
const currentCape = computed(() => {
if (selectedSkin.value?.cape_id) {
const overrideCape = capes.value.find((c) => c.id === selectedSkin.value?.cape_id)
if (overrideCape) {
return overrideCape
}
}
return defaultCape.value
})
const skinTexture = computedAsync(async () => {
if (selectedSkin.value?.texture) {
return await get_normalized_skin_texture(selectedSkin.value)
} else {
return ''
}
})
const capeTexture = computed(() => currentCape.value?.texture)
const skinVariant = computed(() => selectedSkin.value?.variant)
const skinNametag = computed(() =>
settings.value.hide_nametag_skins_page ? undefined : username.value,
)
let userCheckInterval: number | null = null
const deleteSkinModal = ref()
const skinToDelete = ref<Skin | null>(null)
function confirmDeleteSkin(skin: Skin) {
skinToDelete.value = skin
deleteSkinModal.value?.show()
}
async function deleteSkin() {
if (!skinToDelete.value) return
await remove_custom_skin(skinToDelete.value).catch(handleError)
await loadSkins()
skinToDelete.value = null
}
async function loadCapes() {
try {
capes.value = (await get_available_capes()) ?? []
defaultCape.value = capes.value.find((c) => c.is_equipped)
originalDefaultCape.value = defaultCape.value
} catch (error) {
if (currentUser.value) {
handleError(error)
}
}
}
async function loadSkins() {
try {
skins.value = (await get_available_skins()) ?? []
generateSkinPreviews(skins.value, capes.value)
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
originalSelectedSkin.value = selectedSkin.value
} catch (error) {
if (currentUser.value) {
handleError(error)
}
}
}
async function changeSkin(newSkin: Skin) {
const previousSkin = selectedSkin.value
const previousSkinsList = [...skins.value]
skins.value = skins.value.map((skin) => {
return {
...skin,
is_equipped: skin.texture_key === newSkin.texture_key,
}
})
selectedSkin.value = skins.value.find((s) => s.texture_key === newSkin.texture_key) || null
try {
await equip_skin(newSkin)
if (accountsCard.value) {
await accountsCard.value.refreshValues()
}
} catch (error) {
selectedSkin.value = previousSkin
skins.value = previousSkinsList
if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) {
notifications.addNotification({
type: 'error',
title: 'Slow down!',
text: "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
})
} else {
handleError(error)
}
}
}
async function handleCapeSelected(cape: Cape | undefined) {
const previousDefaultCape = defaultCape.value
const previousCapesList = [...capes.value]
capes.value = capes.value.map((c) => ({
...c,
is_equipped: cape ? c.id === cape.id : false,
}))
defaultCape.value = cape ? capes.value.find((c) => c.id === cape.id) : undefined
try {
await set_default_cape(cape)
} catch (error) {
defaultCape.value = previousDefaultCape
capes.value = previousCapesList
if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) {
notifications.addNotification({
type: 'error',
title: 'Slow down!',
text: "You're changing your cape too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
})
} else {
handleError(error)
}
}
}
async function onSkinSaved() {
await Promise.all([loadCapes(), loadSkins()])
}
async function loadCurrentUser() {
try {
const defaultId = await get_default_user()
currentUserId.value = defaultId
const allAccounts = await users()
currentUser.value = allAccounts.find((acc) => acc.profile.id === defaultId)
} catch (e) {
handleError(e)
currentUser.value = undefined
currentUserId.value = undefined
}
}
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
return skinBlobUrlMap.get(key)
}
async function login() {
accountsCard.value.setLoginDisabled(true)
const loggedIn = await login_flow().catch(handleSevereError)
if (loggedIn && accountsCard) {
await accountsCard.value.refreshValues()
}
trackEvent('AccountLogIn')
accountsCard.value.setLoginDisabled(false)
}
function openUploadSkinModal(e: MouseEvent) {
uploadSkinModal.value?.show(e)
}
function onSkinFileUploaded(buffer: ArrayBuffer) {
const fakeEvent = new MouseEvent('click')
normalize_skin_texture(`data:image/png;base64,` + arrayBufferToBase64(buffer)).then(
(skinTextureNormalized: Uint8Array) => {
const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized)
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
editSkinModal.value.restoreWithNewTexture(skinTexUrl)
} else {
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
}
},
)
}
function onUploadCanceled() {
editSkinModal.value?.restoreModal()
}
watch(
() => selectedSkin.value?.cape_id,
() => {},
)
onMounted(() => {
userCheckInterval = window.setInterval(checkUserChanges, 250)
})
onUnmounted(() => {
if (userCheckInterval !== null) {
window.clearInterval(userCheckInterval)
}
})
async function checkUserChanges() {
try {
const defaultId = await get_default_user()
if (defaultId !== currentUserId.value) {
await loadCurrentUser()
await loadCapes()
await loadSkins()
}
} catch (error) {
if (currentUser.value) {
handleError(error)
}
}
}
await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()])
</script>
<template>
<EditSkinModal
ref="editSkinModal"
:capes="capes"
:default-cape="defaultCape"
@saved="onSkinSaved"
@deleted="() => loadSkins()"
@open-upload-modal="openUploadSkinModal"
/>
<SelectCapeModal ref="selectCapeModal" :capes="capes" @select="handleCapeSelected" />
<UploadSkinModal
ref="uploadSkinModal"
@uploaded="onSkinFileUploaded"
@canceled="onUploadCanceled"
/>
<ConfirmModal
ref="deleteSkinModal"
title="Are you sure you want to delete this skin?"
description="This will permanently delete the selected skin. This action cannot be undone."
proceed-label="Delete"
@proceed="deleteSkin"
/>
<div v-if="currentUser" class="p-4 skin-layout">
<div class="preview-panel">
<h1 class="m-0 text-2xl font-bold flex items-center gap-2">
Skins
<span class="text-sm font-bold px-2 bg-brand-highlight text-brand rounded-full">Beta</span>
</h1>
<div class="preview-container">
<SkinPreviewRenderer
:cape-src="capeTexture"
:texture-src="skinTexture || ''"
:variant="skinVariant"
:nametag="skinNametag"
:initial-rotation="Math.PI / 8"
>
<template #subtitle>
<ButtonStyled :disabled="!!selectedSkin?.cape_id">
<button
v-tooltip="
selectedSkin?.cape_id
? 'The equipped skin is overriding the default cape.'
: undefined
"
:disabled="!!selectedSkin?.cape_id"
@click="
(e: MouseEvent) =>
selectCapeModal?.show(
e,
selectedSkin?.texture_key,
currentCape,
skinTexture,
skinVariant,
)
"
>
<UpdatedIcon />
Change cape
</button>
</ButtonStyled>
</template>
</SkinPreviewRenderer>
</div>
</div>
<div class="skins-container">
<section class="flex flex-col gap-2 mt-1">
<h2 class="text-lg font-bold m-0 text-primary">Saved skins</h2>
<div class="skin-card-grid">
<SkinLikeTextButton class="skin-card" @click="openUploadSkinModal">
<template #icon>
<PlusIcon class="size-8" />
</template>
<span>Add a skin</span>
</SkinLikeTextButton>
<SkinButton
v-for="skin in savedSkins"
:key="`saved-skin-${skin.texture_key}`"
class="skin-card"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="selectedSkin === skin"
@select="changeSkin(skin)"
>
<template #overlay-buttons>
<Button
color="green"
aria-label="Edit skin"
class="pointer-events-auto"
@click.stop="(e) => editSkinModal?.show(e, skin)"
>
<EditIcon /> Edit
</Button>
<Button
v-show="!skin.is_equipped"
v-tooltip="'Delete skin'"
aria-label="Delete skin"
color="red"
class="!rounded-[100%] pointer-events-auto"
icon-only
@click.stop="() => confirmDeleteSkin(skin)"
>
<TrashIcon />
</Button>
</template>
</SkinButton>
</div>
</section>
<section class="flex flex-col gap-2 mt-6">
<h2 class="text-lg font-bold m-0 text-primary">Default skins</h2>
<div class="skin-card-grid">
<SkinButton
v-for="skin in defaultSkins"
:key="`default-skin-${skin.texture_key}`"
class="skin-card"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="selectedSkin === skin"
:tooltip="skin.name"
@select="changeSkin(skin)"
/>
</div>
</section>
</div>
</div>
<div v-else class="flex items-center justify-center min-h-[50vh] pt-[25%]">
<div
class="bg-bg-raised rounded-lg p-7 flex flex-col gap-5 shadow-md relative max-w-xl w-full mx-auto"
>
<img
:src="ExcitedRinthbot"
alt="Excited Modrinth Bot"
class="absolute -top-28 right-8 md:right-20 h-28 w-auto"
/>
<div
class="absolute top-0 left-0 w-full h-[1px] opacity-40 bg-gradient-to-r from-transparent via-green-500 to-transparent"
style="
background: linear-gradient(
to right,
transparent 2rem,
var(--color-green) calc(100% - 13rem),
var(--color-green) calc(100% - 5rem),
transparent calc(100% - 2rem)
);
"
></div>
<div class="flex flex-col gap-5">
<h1 class="text-3xl font-extrabold m-0">Please sign-in</h1>
<p class="text-lg m-0">
Please sign into your Minecraft account to use the skin management features of the
Modrinth app.
</p>
<ButtonStyled v-show="accountsCard" color="brand" :disabled="accountsCard.loginDisabled">
<button :disabled="accountsCard.loginDisabled" @click="login">
<LogInIcon v-if="!accountsCard.loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
Sign In
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
$skin-card-width: 155px;
$skin-card-gap: 4px;
.skin-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2.5fr);
gap: 2.5rem;
@media (max-width: 700px) {
grid-template-columns: 1fr;
}
}
.preview-panel {
top: 1.5rem;
position: sticky;
align-self: start;
padding: 0.5rem;
padding-top: 0;
}
.preview-container {
height: 80vh;
display: flex;
align-items: center;
justify-content: center;
margin-left: calc((2.5rem / 2));
@media (max-width: 700px) {
height: 50vh;
}
}
.skins-container {
padding-top: 0.5rem;
}
.skin-card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $skin-card-gap;
width: 100%;
@media (min-width: 1300px) {
grid-template-columns: repeat(4, 1fr);
}
@media (min-width: 1750px) {
grid-template-columns: repeat(5, 1fr);
}
@media (min-width: 2050px) {
grid-template-columns: repeat(6, 1fr);
}
}
.skin-card {
aspect-ratio: 0.95;
border-radius: 10px;
box-sizing: border-box;
width: 100%;
min-width: 0;
}
</style>

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