Compare commits

..

2 Commits

Author SHA1 Message Date
Josiah Glosson
ee9d4fad33 Add quotes around $env:JAVA_HOME_11_X64
Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Signed-off-by: Josiah Glosson <soujournme@gmail.com>
2025-06-27 10:29:10 -05:00
Josiah Glosson
140b4060e6 Set JAVA_HOME to JAVA_HOME_11_X64 on Windows for theseus-release 2025-06-26 16:51:46 -05:00
562 changed files with 2818 additions and 25230 deletions

View File

@@ -1 +0,0 @@
.gitignore

34
.gitattributes vendored
View File

@@ -1,35 +1 @@
* text=auto eol=lf * 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

@@ -18,6 +18,9 @@ on:
jobs: jobs:
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults:
run:
working-directory: ./apps/labrinth
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2
@@ -35,6 +38,8 @@ jobs:
- name: Build and push - name: Build and push
id: docker_build id: docker_build
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
env:
SQLX_OFFLINE: true
with: with:
file: ./apps/labrinth/Dockerfile file: ./apps/labrinth/Dockerfile
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}

View File

@@ -1,150 +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
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'
- 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,186 @@
name: Modrinth App release name: 'Modrinth App build'
on: on:
push:
branches:
- main
tags:
- 'v*'
paths:
- .github/workflows/theseus-release.yml
- 'apps/app/**'
- 'apps/app-frontend/**'
- 'packages/app-lib/**'
- 'packages/app-macros/**'
- 'packages/assets/**'
- 'packages/ui/**'
- 'packages/utils/**'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version-tag: sign-windows-binaries:
description: Version tag to release to the wide public description: Sign Windows binaries
type: string type: boolean
required: true default: true
release-notes: required: false
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: jobs:
release: build:
name: Release Modrinth App strategy:
runs-on: ubuntu-latest fail-fast: false
matrix:
platform: [macos-latest, windows-latest, ubuntu-22.04]
env: runs-on: ${{ matrix.platform }}
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
steps: steps:
- name: 📥 Download Modrinth App artifacts - uses: actions/checkout@v4
uses: dawidd6/action-download-artifact@v11
- name: Rust setup (mac)
if: startsWith(matrix.platform, 'macos')
uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
workflow: theseus-build.yml rustflags: ''
workflow_conclusion: success target: x86_64-apple-darwin
event: push
branch: ${{ inputs.version-tag }}
use_unzip: true
- name: 🛠️ Generate version manifest - name: Rust setup
env: if: "!startsWith(matrix.platform, 'macos')"
VERSION_TAG: ${{ inputs.version-tag }} uses: actions-rust-lang/setup-rust-toolchain@v1
RELEASE_NOTES: ${{ inputs.release-notes }} with:
rustflags: ''
- 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: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Install pnpm via corepack
shell: bash
run: | run: |
# Reference: https://tauri.app/plugin/updater/#server-support corepack enable
jq -nc \ corepack prepare --activate
--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
echo "Generated manifest for version ${VERSION_TAG}:" - name: Get pnpm store directory
cat updates.json id: pnpm-cache
shell: bash
- 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
run: | run: |
for macosBundleType in 'macos' 'dmg'; do echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
aws s3 cp --recursive \
"${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/${macosBundleType}" \
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/macos"
done
for linuxBundleType in 'appimage' 'deb' 'rpm'; do - name: Setup pnpm cache
aws s3 cp --recursive \ uses: actions/cache@v4
"${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${linuxBundleType}" \ with:
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/linux" path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
done key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
for windowsBundleType in 'nsis'; do - name: install dependencies (ubuntu only)
aws s3 cp --recursive \ if: startsWith(matrix.platform, 'ubuntu')
"${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${windowsBundleType}" \ run: |
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/windows" sudo apt-get update
done 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 code signing client (Windows only)
if: startsWith(matrix.platform, 'windows')
run: choco install jsign --ignore-dependencies # GitHub runners come with a global Java installation already
- name: Install frontend dependencies
run: pnpm install
- name: Disable Windows code signing for non-final release builds
if: ${{ startsWith(matrix.platform, 'windows') && !startsWith(github.ref, 'refs/tags/v') && !inputs.sign-windows-binaries }}
run: |
jq 'del(.bundle.windows.signCommand)' apps/app/tauri-release.conf.json > apps/app/tauri-release.conf.json.new
Move-Item -Path apps/app/tauri-release.conf.json.new -Destination apps/app/tauri-release.conf.json -Force
- 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 (Linux)
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
if: startsWith(matrix.platform, 'ubuntu')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: build app (Windows)
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
if: startsWith(matrix.platform, 'windows')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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 ${{ 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

@@ -76,8 +76,3 @@ jobs:
- name: 🔍 Lint and test - name: 🔍 Lint and test
run: pnpm run ci 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

726
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,7 @@ actix-ws = "0.3.0"
argon2 = { version = "0.5.3", features = ["std"] } argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" } ariadne = { path = "packages/ariadne" }
async_zip = "0.0.17" async_zip = "0.0.17"
async-compression = { version = "0.4.25", default-features = false } async-compression = { version = "0.4.24", default-features = false }
async-recursion = "1.1.1" async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [ async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls", "runtime-tokio-hyper-rustls",
@@ -37,7 +37,6 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [
async-walkdir = "2.1.0" async-walkdir = "2.1.0"
base64 = "0.22.1" base64 = "0.22.1"
bitflags = "2.9.1" bitflags = "2.9.1"
bytemuck = "1.23.0"
bytes = "1.10.1" bytes = "1.10.1"
censor = "0.3.0" censor = "0.3.0"
chardetng = "0.1.17" chardetng = "0.1.17"
@@ -48,7 +47,6 @@ color-thief = "0.2.2"
console-subscriber = "0.4.1" console-subscriber = "0.4.1"
daedalus = { path = "packages/daedalus" } daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0" dashmap = "6.1.0"
data-url = "0.3.1"
deadpool-redis = "0.21.1" deadpool-redis = "0.21.1"
dirs = "6.0.0" dirs = "6.0.0"
discord-rich-presence = "0.2.5" discord-rich-presence = "0.2.5"
@@ -63,16 +61,10 @@ fs4 = { version = "0.13.1", default-features = false }
futures = { version = "0.3.31", default-features = false } futures = { version = "0.3.31", default-features = false }
futures-util = "0.3.31" futures-util = "0.3.31"
hashlink = "0.10.0" hashlink = "0.10.0"
heck = "0.5.0"
hex = "0.4.3" hex = "0.4.3"
hickory-resolver = "0.25.2" hickory-resolver = "0.25.2"
hmac = "0.12.1" hmac = "0.12.1"
hyper-rustls = { version = "0.27.7", default-features = false, features = [ hyper-tls = "0.6.0"
"http1",
"native-tokio",
"ring",
"tls12",
] }
hyper-util = "0.1.14" hyper-util = "0.1.14"
iana-time-zone = "0.1.63" iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] } image = { version = "0.25.6", default-features = false, features = ["rayon"] }
@@ -98,7 +90,6 @@ notify = { version = "8.0.0", default-features = false }
notify-debouncer-mini = { version = "0.6.0", default-features = false } notify-debouncer-mini = { version = "0.6.0", default-features = false }
p256 = "0.13.2" p256 = "0.13.2"
paste = "1.0.15" paste = "1.0.15"
png = "0.17.16"
prometheus = "0.14.0" prometheus = "0.14.0"
quartz_nbt = "0.2.9" quartz_nbt = "0.2.9"
quick-xml = "0.37.5" quick-xml = "0.37.5"
@@ -106,9 +97,8 @@ 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 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 redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
regex = "1.11.1" regex = "1.11.1"
reqwest = { version = "0.12.20", default-features = false } reqwest = { version = "0.12.19", default-features = false }
rgb = "0.8.50" rust_decimal = { version = "1.37.1", features = [
rust_decimal = { version = "1.37.2", features = [
"serde-with-float", "serde-with-float",
"serde-with-str", "serde-with-str",
] } ] }
@@ -119,7 +109,7 @@ rust-s3 = { version = "0.35.1", default-features = false, features = [
"tokio-rustls-tls", "tokio-rustls-tls",
] } ] }
rusty-money = "0.4.1" rusty-money = "0.4.1"
sentry = { version = "0.41.0", default-features = false, features = [ sentry = { version = "0.38.1", default-features = false, features = [
"backtrace", "backtrace",
"contexts", "contexts",
"debug-images", "debug-images",
@@ -127,13 +117,13 @@ sentry = { version = "0.41.0", default-features = false, features = [
"reqwest", "reqwest",
"rustls", "rustls",
] } ] }
sentry-actix = "0.41.0" sentry-actix = "0.38.1"
serde = "1.0.219" serde = "1.0.219"
serde_bytes = "0.11.17" serde_bytes = "0.11.17"
serde_cbor = "0.11.2" serde_cbor = "0.11.2"
serde_ini = "0.2.0" serde_ini = "0.2.0"
serde_json = "1.0.140" serde_json = "1.0.140"
serde_with = "3.13.0" serde_with = "3.12.0"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6" sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] } sha1_smol = { version = "1.0.1", features = ["std"] }
@@ -142,19 +132,18 @@ spdx = "0.10.8"
sqlx = { version = "0.8.6", default-features = false } sqlx = { version = "0.8.6", default-features = false }
sysinfo = { version = "0.35.2", default-features = false } sysinfo = { version = "0.35.2", default-features = false }
tar = "0.4.44" tar = "0.4.44"
tauri = "2.6.1" tauri = "2.5.1"
tauri-build = "2.3.0" tauri-build = "2.2.0"
tauri-plugin-deep-link = "2.4.0" tauri-plugin-deep-link = "2.3.0"
tauri-plugin-dialog = "2.3.0" tauri-plugin-dialog = "2.2.2"
tauri-plugin-http = "2.5.0" tauri-plugin-opener = "2.2.7"
tauri-plugin-opener = "2.4.0" tauri-plugin-os = "2.2.1"
tauri-plugin-os = "2.3.0" tauri-plugin-single-instance = "2.2.4"
tauri-plugin-single-instance = "2.3.0" tauri-plugin-updater = { version = "2.7.1", default-features = false, features = [
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
"rustls-tls", "rustls-tls",
"zip", "zip",
] } ] }
tauri-plugin-window-state = "2.3.0" tauri-plugin-window-state = "2.2.2"
tempfile = "3.20.0" tempfile = "3.20.0"
theseus = { path = "packages/app-lib" } theseus = { path = "packages/app-lib" }
thiserror = "2.0.12" thiserror = "2.0.12"
@@ -177,7 +166,7 @@ whoami = "1.6.0"
winreg = "0.55.0" winreg = "0.55.0"
woothee = "0.13.0" woothee = "0.13.0"
yaserde = "0.12.0" yaserde = "0.12.0"
zip = { version = "4.2.0", default-features = false, features = [ zip = { version = "4.0.0", default-features = false, features = [
"bzip2", "bzip2",
"deflate", "deflate",
"deflate64", "deflate64",
@@ -224,7 +213,7 @@ wildcard_dependencies = "warn"
warnings = "deny" warnings = "deny"
[patch.crates-io] [patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "21db186" } wry = { git = "https://github.com/modrinth/wry", rev = "cafdaa9" }
# Optimize for speed and reduce size on release builds # Optimize for speed and reduce size on release builds
[profile.release] [profile.release]

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"name": "@modrinth/app-frontend", "name": "@modrinth/app-frontend",
"private": true, "private": true,
"version": "1.0.0-local", "version": "0.9.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -20,20 +20,16 @@
"@sentry/vue": "^8.27.0", "@sentry/vue": "^8.27.0",
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1", "@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-http": "^2.5.0",
"@tauri-apps/plugin-opener": "^2.2.6", "@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2", "@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0",
"@vintl/vintl": "^4.4.1", "@vintl/vintl": "^4.4.1",
"@vueuse/core": "^11.1.0",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"ofetch": "^1.3.4", "ofetch": "^1.3.4",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"posthog-js": "^1.158.2", "posthog-js": "^1.158.2",
"three": "^0.172.0",
"vite-svg-loader": "^5.1.0", "vite-svg-loader": "^5.1.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-multiselect": "3.0.0", "vue-multiselect": "3.0.0",

View File

@@ -1,9 +1,8 @@
<script setup> <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 { RouterView, useRoute, useRouter } from 'vue-router'
import { import {
ArrowBigUpDashIcon, ArrowBigUpDashIcon,
ChangeSkinIcon,
CompassIcon, CompassIcon,
DownloadIcon, DownloadIcon,
HomeIcon, HomeIcon,
@@ -19,7 +18,6 @@ import {
SettingsIcon, SettingsIcon,
WorldIcon, WorldIcon,
XIcon, XIcon,
NewspaperIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
Avatar, Avatar,
@@ -27,7 +25,7 @@ import {
ButtonStyled, ButtonStyled,
Notifications, Notifications,
OverflowMenu, OverflowMenu,
NewsArticleCard, useRelativeTime,
} from '@modrinth/ui' } from '@modrinth/ui'
import { useLoading, useTheming } from '@/store/state' import { useLoading, useTheming } from '@/store/state'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component' import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
@@ -64,13 +62,14 @@ import NavButton from '@/components/ui/NavButton.vue'
import { 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 { get_user } from '@/helpers/cache.js'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import dayjs from 'dayjs'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue' import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js' import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue' import FriendsList from '@/components/ui/friends/FriendsList.vue'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue' 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 formatRelativeTime = useRelativeTime()
const themeStore = useTheming() const themeStore = useTheming()
@@ -178,7 +177,6 @@ async function setupApp() {
'criticalAnnouncements', 'criticalAnnouncements',
true, true,
) )
.then((response) => response.json())
.then((res) => { .then((res) => {
if (res && res.header && res.body) { if (res && res.header && res.body) {
criticalErrorMessage.value = res criticalErrorMessage.value = res
@@ -190,35 +188,15 @@ async function setupApp() {
) )
}) })
useFetch(`https://modrinth.com/news/feed/articles.json`, 'news', true) useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => {
.then((response) => response.json()) if (res && res.articles) {
.then((res) => { news.value = res.articles
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)
}
})
get_opening_command().then(handleCommand) get_opening_command().then(handleCommand)
checkUpdates() checkUpdates()
fetchCredentials() 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) const stateFailed = ref(false)
@@ -326,7 +304,6 @@ onMounted(() => {
}) })
const accounts = ref(null) const accounts = ref(null)
provide('accountsCard', accounts)
command_listener(handleCommand) command_listener(handleCommand)
async function handleCommand(e) { async function handleCommand(e) {
@@ -422,9 +399,6 @@ function handleAuxClick(e) {
> >
<CompassIcon /> <CompassIcon />
</NavButton> </NavButton>
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
<ChangeSkinIcon />
</NavButton>
<NavButton <NavButton
v-tooltip.right="'Library'" v-tooltip.right="'Library'"
to="/library" to="/library"
@@ -485,13 +459,13 @@ function handleAuxClick(e) {
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" /> <ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
<div class="flex items-center gap-1 ml-3"> <div class="flex items-center gap-1 ml-3">
<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.back()" @click="router.back()"
> >
<LeftArrowIcon /> <LeftArrowIcon />
</button> </button>
<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()" @click="router.forward()"
> >
<RightArrowIcon /> <RightArrowIcon />
@@ -605,20 +579,34 @@ function handleAuxClick(e) {
<FriendsList :credentials="credentials" :sign-in="() => signIn()" /> <FriendsList :credentials="credentials" :sign-in="() => signIn()" />
</suspense> </suspense>
</div> </div>
<div v-if="news && news.length > 0" class="pt-4 flex flex-col items-center"> <div v-if="news && news.length > 0" class="pt-4 flex flex-col">
<h3 class="px-4 text-lg m-0 text-left w-full">News</h3> <h3 class="px-4 text-lg m-0">News</h3>
<div class="px-4 pt-2 space-y-4 flex flex-col items-center w-full"> <template v-for="(item, index) in news" :key="`news-${index}`">
<NewsArticleCard <a
v-for="(item, index) in news" :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'}`"
:key="`news-${index}`" :href="item.link"
:article="item" 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">
{{ formatRelativeTime(dayjs(item.date).toISOString()) }}
</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"> </template>
<a href="https://modrinth.com/news" target="_blank" class="my-4">
<NewspaperIcon /> View all news
</a>
</ButtonStyled>
</div>
</div> </div>
</div> </div>
</div> </div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -136,7 +136,7 @@ const filteredResults = computed(() => {
if (sortBy.value === 'Game version') { if (sortBy.value === 'Game version') {
instances.sort((a, b) => { 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]) 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 return instanceMap
}) })

View File

@@ -9,11 +9,13 @@
<Avatar <Avatar
size="36px" size="36px"
:src=" :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"> <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> <span class="text-secondary text-xs">Minecraft account</span>
</div> </div>
<DropdownIcon class="w-5 h-5 shrink-0" /> <DropdownIcon class="w-5 h-5 shrink-0" />
@@ -26,40 +28,28 @@
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }" :class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
> >
<div v-if="selectedAccount" class="selected account"> <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> <div>
<h4>{{ selectedAccount.profile.name }}</h4> <h4>{{ selectedAccount.username }}</h4>
<p>Selected</p> <p>Selected</p>
</div> </div>
<Button <Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.id)">
v-tooltip="'Log out'"
icon-only
color="raised"
@click="logout(selectedAccount.profile.id)"
>
<TrashIcon /> <TrashIcon />
</Button> </Button>
</div> </div>
<div v-else class="logged-out account"> <div v-else class="logged-out account">
<h4>Not signed in</h4> <h4>Not signed in</h4>
<Button <Button v-tooltip="'Log in'" icon-only color="primary" @click="login()">
v-tooltip="'Log in'" <LogInIcon />
:disabled="loginDisabled"
icon-only
color="primary"
@click="login()"
>
<LogInIcon v-if="!loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button> </Button>
</div> </div>
<div v-if="displayAccounts.length > 0" class="account-group"> <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)"> <Button class="option account" @click="setAccount(account)">
<Avatar :src="getAccountAvatarUrl(account)" class="icon" /> <Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
<p>{{ account.profile.name }}</p> <p>{{ account.username }}</p>
</Button> </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 /> <TrashIcon />
</Button> </Button>
</div> </div>
@@ -73,7 +63,7 @@
</template> </template>
<script setup> <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 { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import { import {
@@ -87,8 +77,6 @@ import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { get_available_skins } from '@/helpers/skins'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
defineProps({ defineProps({
mode: { mode: {
@@ -101,86 +89,32 @@ defineProps({
const emit = defineEmits(['change']) const emit = defineEmits(['change'])
const accounts = ref({}) const accounts = ref({})
const loginDisabled = ref(false)
const defaultUser = ref() const defaultUser = ref()
const equippedSkin = ref(null)
const headUrlCache = ref(new Map())
async function refreshValues() { async function refreshValues() {
defaultUser.value = await get_default_user().catch(handleError) defaultUser.value = await get_default_user().catch(handleError)
accounts.value = await users().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({ defineExpose({
refreshValues, refreshValues,
setLoginDisabled,
loginDisabled,
}) })
await refreshValues() await refreshValues()
const displayAccounts = computed(() => 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(() => const selectedAccount = computed(() =>
accounts.value.find((account) => account.profile.id === defaultUser.value), accounts.value.find((account) => account.id === defaultUser.value),
) )
async function setAccount(account) { async function setAccount(account) {
defaultUser.value = account.profile.id defaultUser.value = account.id
await set_default_user(account.profile.id).catch(handleError) await set_default_user(account.id).catch(handleError)
emit('change') emit('change')
} }
async function login() { async function login() {
loginDisabled.value = true
const loggedIn = await login_flow().catch(handleSevereError) const loggedIn = await login_flow().catch(handleSevereError)
if (loggedIn) { if (loggedIn) {
@@ -189,7 +123,6 @@ async function login() {
} }
trackEvent('AccountLogIn') trackEvent('AccountLogIn')
loginDisabled.value = false
} }
const logout = async (id) => { const logout = async (id) => {

View File

@@ -92,7 +92,7 @@ async function loginMinecraft() {
const loggedIn = await login_flow() const loggedIn = await login_flow()
if (loggedIn) { if (loggedIn) {
await set_default_user(loggedIn.profile.id).catch(handleError) await set_default_user(loggedIn.id).catch(handleError)
} }
await trackEvent('AccountLogIn', { source: 'ErrorModal' }) await trackEvent('AccountLogIn', { source: 'ErrorModal' })
@@ -219,8 +219,8 @@ async function copyToClipboard(text) {
<template v-else-if="metadata.notEnoughSpace"> <template v-else-if="metadata.notEnoughSpace">
<h3>Not enough space</h3> <h3>Not enough space</h3>
<p> <p>
It looks like there is not enough space on the disk containing the directory you 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. selected Please free up some space and try again or cancel the directory change.
</p> </p>
</template> </template>
<template v-else> <template v-else>

View File

@@ -19,6 +19,7 @@ import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { formatCategory } from '@modrinth/utils'
const formatRelativeTime = useRelativeTime() const formatRelativeTime = useRelativeTime()
@@ -172,10 +173,7 @@ onUnmounted(() => unlisten())
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold"> <div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon /> <TimerIcon />
<span class="text-sm"> <span class="text-sm">
<template v-if="instance.last_played"> Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
</template>
<template v-else> Never played </template>
</span> </span>
</div> </div>
</div> </div>
@@ -239,8 +237,8 @@ onUnmounted(() => unlisten())
</p> </p>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto"> <div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
<GameIcon class="shrink-0" /> <GameIcon class="shrink-0" />
<span class="text-sm capitalize"> <span class="text-sm">
{{ instance.loader }} {{ instance.game_version }} {{ formatCategory(instance.loader) }} {{ instance.game_version }}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -108,6 +108,7 @@ async function testJava() {
testingJava.value = true testingJava.value = true
testingJavaSuccess.value = await test_jre( testingJavaSuccess.value = await test_jre(
props.modelValue ? props.modelValue.path : '', props.modelValue ? props.modelValue.path : '',
1,
props.version, props.version,
) )
testingJava.value = false testingJava.value = false

View File

@@ -32,33 +32,8 @@ function updateAdPosition() {
<template> <template>
<div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg"> <div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
<a <div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
href="https://modrinth.gg?from=app-placeholder" <p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
target="_blank" </div>
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> </div>
</template> </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 return dateB - dateA
}) })
.slice(0, 3) .slice(0, 4)
} }
await getInstances() await getInstances()

View File

@@ -6,9 +6,9 @@ import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { handleError } from '@/store/notifications' import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import JavaSelector from '@/components/ui/JavaSelector.vue' import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_max_memory } from '@/helpers/jre'
import { get } from '@/helpers/settings.ts' import { get } from '@/helpers/settings.ts'
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types' import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
import useMemorySlider from '@/composables/useMemorySlider'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -34,7 +34,7 @@ const envVars = ref(
const overrideMemorySettings = ref(!!props.instance.memory) const overrideMemorySettings = ref(!!props.instance.memory)
const memory = ref(props.instance.memory ?? globalSettings.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 editProfileObject = computed(() => {
const editProfile: { const editProfile: {
@@ -156,8 +156,6 @@ const messages = defineMessages({
:min="512" :min="512"
:max="maxMemory" :max="maxMemory"
:step="64" :step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB" unit="MB"
/> />
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block"> <h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">

View File

@@ -5,7 +5,7 @@ import {
ShieldIcon, ShieldIcon,
SettingsIcon, SettingsIcon,
GaugeIcon, GaugeIcon,
PaintbrushIcon, PaintBrushIcon,
GameIcon, GameIcon,
CoffeeIcon, CoffeeIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
@@ -41,7 +41,7 @@ const tabs = [
id: 'app.settings.tabs.appearance', id: 'app.settings.tabs.appearance',
defaultMessage: 'Appearance', defaultMessage: 'Appearance',
}), }),
icon: PaintbrushIcon, icon: PaintBrushIcon,
content: AppearanceSettings, content: AppearanceSettings,
}, },
{ {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateRef } from 'vue' import { ref } from 'vue'
import { NewModal as Modal } from '@modrinth/ui' import { NewModal as Modal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js' import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts' import { useTheming } from '@/store/theme.ts'
@@ -26,16 +26,16 @@ const props = defineProps({
default: true, default: true,
}, },
}) })
const modal = useTemplateRef('modal') const modal = ref(null)
defineExpose({ defineExpose({
show: (e: MouseEvent) => { show: () => {
hide_ads_window() hide_ads_window()
modal.value?.show(e) modal.value.show()
}, },
hide: () => { hide: () => {
onModalHide() onModalHide()
modal.value?.hide() modal.value.hide()
}, },
}) })

View File

@@ -56,17 +56,9 @@ watch(
/> />
</div> </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 v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
<div> <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> <p class="m-0 mt-1">Use system window frame (app restart required).</p>
</div> </div>
<Toggle id="native-decorations" v-model="settings.native_decorations" /> <Toggle id="native-decorations" v-model="settings.native_decorations" />

View File

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

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,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { import {
EyeIcon, EyeIcon,
@@ -43,7 +42,6 @@ const emit = defineEmits<{
const props = defineProps<{ const props = defineProps<{
instance: GameInstance instance: GameInstance
last_played: Dayjs
}>() }>()
const loadingModpack = ref(!!props.instance.linked_data) const loadingModpack = ref(!!props.instance.linked_data)
@@ -149,12 +147,12 @@ onUnmounted(() => {
: null : null
" "
class="w-fit shrink-0" class="w-fit shrink-0"
:class="{ 'cursor-help smart-clickable:allow-pointer-events': last_played }" :class="{ 'cursor-help smart-clickable:allow-pointer-events': instance.last_played }"
> >
<template v-if="last_played"> <template v-if="instance.last_played">
{{ {{
formatMessage(commonMessages.playedLabel, { formatMessage(commonMessages.playedLabel, {
time: formatRelativeTime(last_played.toISOString?.()), time: formatRelativeTime(instance.last_played.toISOString()),
}) })
}} }}
</template> </template>

View File

@@ -84,7 +84,7 @@ async function populateJumpBackIn() {
worldItems.push({ worldItems.push({
type: 'world', type: 'world',
last_played: dayjs(world.last_played ?? 0), last_played: dayjs(world.last_played),
world: world, world: world,
instance: instance, instance: instance,
}) })
@@ -138,13 +138,13 @@ async function populateJumpBackIn() {
instanceItems.push({ instanceItems.push({
type: 'instance', type: 'instance',
last_played: dayjs(instance.last_played ?? 0), last_played: dayjs(instance.last_played),
instance: instance, instance: instance,
}) })
} }
const items: JumpBackInItem[] = [...worldItems, ...instanceItems] const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
items.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0))) items.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played)))
jumpBackInItems.value = items jumpBackInItems.value = items
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO)) .filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO))
.slice(0, MAX_JUMP_BACK_IN) .slice(0, MAX_JUMP_BACK_IN)
@@ -291,7 +291,7 @@ onUnmounted(() => {
" "
@stop="() => stopInstance(item.instance.path)" @stop="() => stopInstance(item.instance.path)"
/> />
<InstanceItem v-else :instance="item.instance" :last_played="item.last_played" /> <InstanceItem v-else :instance="item.instance" />
</template> </template>
</div> </div>
</div> </div>

View File

@@ -128,14 +128,6 @@ const messages = defineMessages({
id: 'instance.worlds.game_already_open', id: 'instance.worlds.game_already_open',
defaultMessage: 'Instance is 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: { copyAddress: {
id: 'instance.worlds.copy_address', id: 'instance.worlds.copy_address',
defaultMessage: 'Copy address', defaultMessage: 'Copy address',
@@ -310,33 +302,39 @@ const messages = defineMessages({
</template> </template>
</div> </div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events"> <div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<ButtonStyled <template v-if="world.type === 'singleplayer' || serverStatus">
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance" <ButtonStyled
color="red" v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
> color="red"
<button @click="emit('stop')"> >
<StopCircleIcon aria-hidden="true" /> <button @click="emit('stop')">
{{ formatMessage(commonMessages.stopButton) }} <StopCircleIcon aria-hidden="true" />
</button> {{ formatMessage(commonMessages.stopButton) }}
</ButtonStyled> </button>
<ButtonStyled v-else> </ButtonStyled>
<button <ButtonStyled v-else>
v-tooltip=" <button
!serverStatus v-tooltip="
? formatMessage(messages.noContact) serverIncompatible
: serverIncompatible ? 'Server is incompatible'
? formatMessage(messages.incompatibleServer)
: !supportsQuickPlay : !supportsQuickPlay
? formatMessage(messages.noQuickPlay) ? formatMessage(messages.noQuickPlay)
: playingOtherWorld || locked : playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen) ? formatMessage(messages.gameAlreadyOpen)
: null : null
" "
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance" :disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
@click="emit('play')" @click="emit('play')"
> >
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" /> <SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" /> <PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else>
<button class="invisible">
<PlayIcon aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }} {{ formatMessage(commonMessages.playButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>

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

View File

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

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

@@ -37,7 +37,6 @@ export type AppSettings = {
theme: ColorTheme theme: ColorTheme
default_page: 'home' | 'library' default_page: 'home' | 'library'
collapsed_navigation: boolean collapsed_navigation: boolean
hide_nametag_skins_page: boolean
advanced_rendering: boolean advanced_rendering: boolean
native_decorations: boolean native_decorations: boolean
toggle_sidebar: boolean toggle_sidebar: boolean

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

@@ -377,12 +377,6 @@
"instance.worlds.hardcore": { "instance.worlds.hardcore": {
"message": "Hardcore mode" "message": "Hardcore mode"
}, },
"instance.worlds.incompatible_server": {
"message": "Server is incompatible"
},
"instance.worlds.no_contact": {
"message": "Server couldn't be contacted"
},
"instance.worlds.no_quick_play": { "instance.worlds.no_quick_play": {
"message": "You can only jump straight into worlds on Minecraft 1.20+" "message": "You can only jump straight into worlds on Minecraft 1.20+"
}, },

View File

@@ -220,7 +220,6 @@ async function refreshSearch() {
} }
} }
results.value = rawResults.result results.value = rawResults.result
currentPage.value = 1
const persistentParams: LocationQuery = {} const persistentParams: LocationQuery = {}
@@ -266,7 +265,6 @@ async function onSearchChangeToTop() {
function clearSearch() { function clearSearch() {
query.value = '' query.value = ''
currentPage.value = 1
} }
watch( watch(

View File

@@ -10,7 +10,6 @@ import dayjs from 'dayjs'
import { get_search_results } from '@/helpers/cache.js' import { get_search_results } from '@/helpers/cache.js'
import type { SearchResult } from '@modrinth/utils' import type { SearchResult } from '@modrinth/utils'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue' import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
import type { GameInstance } from '@/helpers/types'
const route = useRoute() const route = useRoute()
const breadcrumbs = useBreadcrumbs() const breadcrumbs = useBreadcrumbs()
@@ -83,15 +82,13 @@ async function refreshFeaturedProjects() {
await fetchInstances() await fetchInstances()
await refreshFeaturedProjects() await refreshFeaturedProjects()
const unlistenProfile = await profile_listener( const unlistenProfile = await profile_listener(async (e) => {
async (e: { event: string; profile_path_id: string }) => { await fetchInstances()
await fetchInstances()
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') { if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
await refreshFeaturedProjects() await refreshFeaturedProjects()
} }
}, })
)
onUnmounted(() => { onUnmounted(() => {
unlistenProfile() unlistenProfile()
@@ -100,8 +97,8 @@ onUnmounted(() => {
<template> <template>
<div class="p-6 flex flex-col gap-2"> <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-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
<h1 v-else class="m-0 text-2xl font-extrabold">Welcome to Modrinth App!</h1> <h1 v-else class="m-0 text-2xl">Welcome to Modrinth App!</h1>
<RecentWorldsList :recent-instances="recentInstances" /> <RecentWorldsList :recent-instances="recentInstances" />
<RowDisplay <RowDisplay
v-if="hasFeaturedProjects" v-if="hasFeaturedProjects"

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>

View File

@@ -1,6 +1,5 @@
import Index from './Index.vue' import Index from './Index.vue'
import Browse from './Browse.vue' import Browse from './Browse.vue'
import Worlds from './Worlds.vue' import Worlds from './Worlds.vue'
import Skins from './Skins.vue'
export { Index, Browse, Worlds, Skins } export { Index, Browse, Worlds }

View File

@@ -483,7 +483,7 @@ onUnmounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
height: 100vh; height: calc(100vh - 11rem);
} }
.button-row { .button-row {

View File

@@ -34,14 +34,6 @@ export default new createRouter({
breadcrumb: [{ name: 'Discover content' }], breadcrumb: [{ name: 'Discover content' }],
}, },
}, },
{
path: '/skins',
name: 'Skins',
component: Pages.Skins,
meta: {
breadcrumb: [{ name: 'Skins' }],
},
},
{ {
path: '/library', path: '/library',
name: 'Library', name: 'Library',

View File

@@ -41,7 +41,6 @@ export default {
green: 'var(--color-green-highlight)', green: 'var(--color-green-highlight)',
blue: 'var(--color-blue-highlight)', blue: 'var(--color-blue-highlight)',
purple: 'var(--color-purple-highlight)', purple: 'var(--color-purple-highlight)',
gray: 'var(--color-gray-highlight)',
}, },
divider: { divider: {
DEFAULT: 'var(--color-divider)', DEFAULT: 'var(--color-divider)',

View File

@@ -10,7 +10,6 @@
"isolatedModules": true, "isolatedModules": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"resolveJsonModule": true,
"strict": true "strict": true
}, },

View File

@@ -4,8 +4,6 @@ import svgLoader from 'vite-svg-loader'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import tauriConf from '../app/tauri.conf.json'
const projectRootDir = resolve(__dirname) const projectRootDir = resolve(__dirname)
// https://vitejs.dev/config/ // https://vitejs.dev/config/
@@ -43,32 +41,17 @@ export default defineConfig({
server: { server: {
port: 1420, port: 1420,
strictPort: true, strictPort: true,
headers: {
'content-security-policy': Object.entries(tauriConf.app.security.csp)
.map(([directive, sources]) => {
// An additional websocket connect-src is required for Vite dev tools to work
if (directive === 'connect-src') {
sources = Array.isArray(sources) ? sources : [sources]
sources.push('ws://localhost:1420')
}
return Array.isArray(sources)
? `${directive} ${sources.join(' ')}`
: `${directive} ${sources}`
})
.join('; '),
},
}, },
// to make use of `TAURI_ENV_DEBUG` and other env variables // to make use of `TAURI_ENV_DEBUG` and other env variables
// https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands // https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands
envPrefix: ['VITE_', 'TAURI_'], envPrefix: ['VITE_', 'TAURI_'],
build: { build: {
// Tauri supports es2021 // Tauri supports es2021
target: process.env.TAURI_ENV_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
// don't minify for debug builds // don't minify for debug builds
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
// produce sourcemaps for debug builds // produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_ENV_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars sourcemap: !!process.env.TAURI_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
commonjsOptions: { commonjsOptions: {
esmExternals: true, esmExternals: true,
}, },

View File

@@ -27,10 +27,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
let credentials = minecraft_auth::finish_login(&input, login).await?; let credentials = minecraft_auth::finish_login(&input, login).await?;
println!( println!("Logged in user {}.", credentials.username);
"Logged in user {}.",
credentials.maybe_online_profile().await.name
);
Ok(credentials) Ok(credentials)
} }

4
apps/app/.gitignore vendored
View File

@@ -1,2 +1,6 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by tauri, metadata generated at compile time # Generated by tauri, metadata generated at compile time
/gen/ /gen/

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "theseus_gui" name = "theseus_gui"
version = "1.0.0-local" # The actual version is set by the theseus-build workflow on tagging version = "0.9.5"
description = "The Modrinth App is a desktop application for managing your Minecraft mods" description = "The Modrinth App is a desktop application for managing your Minecraft mods"
license = "GPL-3.0-only" license = "GPL-3.0-only"
repository = "https://github.com/modrinth/code/apps/app/" repository = "https://github.com/modrinth/code/apps/app/"
@@ -17,14 +17,13 @@ serde = { workspace = true, features = ["derive"] }
serde_with.workspace = true serde_with.workspace = true
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] } tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] }
tauri-plugin-deep-link.workspace = true
tauri-plugin-dialog.workspace = true
tauri-plugin-http.workspace = true
tauri-plugin-opener.workspace = true
tauri-plugin-os.workspace = true
tauri-plugin-single-instance.workspace = true
tauri-plugin-updater.workspace = true
tauri-plugin-window-state.workspace = true tauri-plugin-window-state.workspace = true
tauri-plugin-deep-link.workspace = true
tauri-plugin-os.workspace = true
tauri-plugin-opener.workspace = true
tauri-plugin-dialog.workspace = true
tauri-plugin-updater.workspace = true
tauri-plugin-single-instance.workspace = true
tokio = { workspace = true, features = ["time"] } tokio = { workspace = true, features = ["time"] }
thiserror.workspace = true thiserror.workspace = true

View File

@@ -18,25 +18,5 @@
<string>A Minecraft mod wants to access your camera.</string> <string>A Minecraft mod wants to access your camera.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>A Minecraft mod wants to access your microphone.</string> <string>A Minecraft mod wants to access your microphone.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>asset.localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>textures.minecraft.net</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
</dict> </dict>
</plist> </plist>

View File

@@ -99,24 +99,6 @@ fn main() {
DefaultPermissionRule::AllowAllCommands, DefaultPermissionRule::AllowAllCommands,
), ),
) )
.plugin(
"minecraft-skins",
InlinedPlugin::new()
.commands(&[
"get_available_capes",
"get_available_skins",
"add_and_equip_custom_skin",
"set_default_cape",
"equip_skin",
"remove_custom_skin",
"unequip_skin",
"normalize_skin_texture",
"get_dragged_skin_data",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin( .plugin(
"mr-auth", "mr-auth",
InlinedPlugin::new() InlinedPlugin::new()
@@ -169,6 +151,7 @@ fn main() {
"profile_update_managed_modrinth_version", "profile_update_managed_modrinth_version",
"profile_repair_managed_modrinth", "profile_repair_managed_modrinth",
"profile_run", "profile_run",
"profile_run_credentials",
"profile_kill", "profile_kill",
"profile_edit", "profile_edit",
"profile_edit_icon", "profile_edit_icon",

View File

@@ -19,21 +19,12 @@
"window-state:default", "window-state:default",
"window-state:allow-restore-state", "window-state:allow-restore-state",
"window-state:allow-save-window-state", "window-state:allow-save-window-state",
{
"identifier": "http:default",
"allow": [
{ "url": "https://modrinth.com/*" },
{ "url": "https://*.modrinth.com/*" }
]
},
"auth:default", "auth:default",
"import:default", "import:default",
"jre:default", "jre:default",
"logs:default", "logs:default",
"metadata:default", "metadata:default",
"minecraft-skins:default",
"mr-auth:default", "mr-auth:default",
"profile-create:default", "profile-create:default",
"pack:default", "pack:default",

View File

@@ -1,104 +0,0 @@
use crate::api::Result;
use std::path::Path;
use theseus::minecraft_skins::{
self, Bytes, Cape, MinecraftSkinVariant, Skin, UrlOrBlob,
};
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("minecraft-skins")
.invoke_handler(tauri::generate_handler![
get_available_capes,
get_available_skins,
add_and_equip_custom_skin,
set_default_cape,
equip_skin,
remove_custom_skin,
unequip_skin,
normalize_skin_texture,
get_dragged_skin_data,
])
.build()
}
/// `invoke('plugin:minecraft-skins|get_available_capes')`
///
/// See also: [minecraft_skins::get_available_capes]
#[tauri::command]
pub async fn get_available_capes() -> Result<Vec<Cape>> {
Ok(minecraft_skins::get_available_capes().await?)
}
/// `invoke('plugin:minecraft-skins|get_available_skins')`
///
/// See also: [minecraft_skins::get_available_skins]
#[tauri::command]
pub async fn get_available_skins() -> Result<Vec<Skin>> {
Ok(minecraft_skins::get_available_skins().await?)
}
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape_override)`
///
/// See also: [minecraft_skins::add_and_equip_custom_skin]
#[tauri::command]
pub async fn add_and_equip_custom_skin(
texture_blob: Bytes,
variant: MinecraftSkinVariant,
cape_override: Option<Cape>,
) -> Result<()> {
Ok(minecraft_skins::add_and_equip_custom_skin(
texture_blob,
variant,
cape_override,
)
.await?)
}
/// `invoke('plugin:minecraft-skins|set_default_cape', cape)`
///
/// See also: [minecraft_skins::set_default_cape]
#[tauri::command]
pub async fn set_default_cape(cape: Option<Cape>) -> Result<()> {
Ok(minecraft_skins::set_default_cape(cape).await?)
}
/// `invoke('plugin:minecraft-skins|equip_skin', skin)`
///
/// See also: [minecraft_skins::equip_skin]
#[tauri::command]
pub async fn equip_skin(skin: Skin) -> Result<()> {
Ok(minecraft_skins::equip_skin(skin).await?)
}
/// `invoke('plugin:minecraft-skins|remove_custom_skin', skin)`
///
/// See also: [minecraft_skins::remove_custom_skin]
#[tauri::command]
pub async fn remove_custom_skin(skin: Skin) -> Result<()> {
Ok(minecraft_skins::remove_custom_skin(skin).await?)
}
/// `invoke('plugin:minecraft-skins|unequip_skin')`
///
/// See also: [minecraft_skins::unequip_skin]
#[tauri::command]
pub async fn unequip_skin() -> Result<()> {
Ok(minecraft_skins::unequip_skin().await?)
}
/// `invoke('plugin:minecraft-skins|normalize_skin_texture')`
///
/// See also: [minecraft_skins::normalize_skin_texture]
#[tauri::command]
pub async fn normalize_skin_texture(texture: UrlOrBlob) -> Result<Bytes> {
Ok(minecraft_skins::normalize_skin_texture(&texture).await?)
}
/// `invoke('plugin:minecraft-skins|get_dragged_skin_data', path)`
///
/// See also: [minecraft_skins::get_dragged_skin_data]
#[tauri::command]
pub async fn get_dragged_skin_data(path: String) -> Result<Bytes> {
let path = Path::new(&path);
Ok(minecraft_skins::get_dragged_skin_data(path).await?)
}

View File

@@ -7,7 +7,6 @@ pub mod import;
pub mod jre; pub mod jre;
pub mod logs; pub mod logs;
pub mod metadata; pub mod metadata;
pub mod minecraft_skins;
pub mod mr_auth; pub mod mr_auth;
pub mod pack; pub mod pack;
pub mod process; pub mod process;

View File

@@ -28,6 +28,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
profile_update_managed_modrinth_version, profile_update_managed_modrinth_version,
profile_repair_managed_modrinth, profile_repair_managed_modrinth,
profile_run, profile_run,
profile_run_credentials,
profile_kill, profile_kill,
profile_edit, profile_edit,
profile_edit_icon, profile_edit_icon,
@@ -255,6 +256,22 @@ pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
Ok(process) Ok(process)
} }
// Run Minecraft using a profile using chosen credentials
// Returns the UUID, which can be used to poll
// for the actual Child in the state.
// invoke('plugin:profile|profile_run_credentials', {path, credentials})')
#[tauri::command]
pub async fn profile_run_credentials(
path: &str,
credentials: Credentials,
) -> Result<ProcessMetadata> {
let process =
profile::run_credentials(path, &credentials, &QuickPlayType::None)
.await?;
Ok(process)
}
#[tauri::command] #[tauri::command]
pub async fn profile_kill(path: &str) -> Result<()> { pub async fn profile_kill(path: &str) -> Result<()> {
profile::kill(path).await?; profile::kill(path).await?;

View File

@@ -183,7 +183,6 @@ fn main() {
let _ = win.set_focus(); let _ = win.set_focus();
} }
})) }))
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
@@ -249,7 +248,6 @@ fn main() {
.plugin(api::logs::init()) .plugin(api::logs::init())
.plugin(api::jre::init()) .plugin(api::jre::init())
.plugin(api::metadata::init()) .plugin(api::metadata::init())
.plugin(api::minecraft_skins::init())
.plugin(api::pack::init()) .plugin(api::pack::init())
.plugin(api::process::init()) .plugin(api::process::init())
.plugin(api::profile::init()) .plugin(api::profile::init())

View File

@@ -41,7 +41,7 @@
] ]
}, },
"productName": "Modrinth App", "productName": "Modrinth App",
"version": "../app-frontend/package.json", "version": "0.9.5",
"mainBinaryName": "Modrinth App", "mainBinaryName": "Modrinth App",
"identifier": "ModrinthApp", "identifier": "ModrinthApp",
"plugins": { "plugins": {
@@ -86,9 +86,9 @@
"capabilities": ["ads", "core", "plugins"], "capabilities": ["ads", "core", "plugins"],
"csp": { "csp": {
"default-src": "'self' customprotocol: asset:", "default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:", "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs",
"font-src": ["https://cdn-raw.modrinth.com/fonts/"], "font-src": ["https://cdn-raw.modrinth.com/fonts/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:", "img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:",
"style-src": "'unsafe-inline' 'self'", "style-src": "'unsafe-inline' 'self'",
"script-src": "https://*.posthog.com 'self'", "script-src": "https://*.posthog.com 'self'",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'", "frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'",

View File

@@ -1,4 +1,5 @@
FROM rust:1.88.0 AS build FROM rust:1.87.0 AS build
ENV PKG_CONFIG_ALLOW_CROSS=1
WORKDIR /usr/src/daedalus WORKDIR /usr/src/daedalus
COPY . . COPY . .
@@ -9,8 +10,11 @@ FROM debian:bookworm-slim
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates openssl \ && apt-get install -y --no-install-recommends ca-certificates openssl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN update-ca-certificates
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
WORKDIR /daedalus_client WORKDIR /daedalus_client

View File

@@ -68,7 +68,7 @@
Support: https://support.modrinth.com Support: https://support.modrinth.com
Status page: https://status.modrinth.com Status page: https://status.modrinth.com
Roadmap: https://roadmap.modrinth.com Roadmap: https://roadmap.modrinth.com
Blog and newsletter: https://modrinth.com/news Blog and newsletter: https://blog.modrinth.com/subscribe?utm_medium=social&utm_source=discord&utm_campaign=welcome
API documentation: https://docs.modrinth.com API documentation: https://docs.modrinth.com
Modrinth source code: https://github.com/modrinth Modrinth source code: https://github.com/modrinth
Help translate Modrinth: https://crowdin.com/project/modrinth Help translate Modrinth: https://crowdin.com/project/modrinth

View File

@@ -19,6 +19,8 @@ From there, you can create the database and perform all database migrations with
sqlx database setup sqlx database setup
``` ```
Finally, if on Linux, you will need the OpenSSL library. On Debian-based systems, this involves the `pkg-config` and `libssl-dev` packages.
To enable labrinth to create a project, you need to add two things. To enable labrinth to create a project, you need to add two things.
1. An entry in the `loaders` table. 1. An entry in the `loaders` table.

View File

@@ -38,12 +38,9 @@
"@intercom/messenger-js-sdk": "^0.0.14", "@intercom/messenger-js-sdk": "^0.0.14",
"@ltd/j-toml": "^1.38.0", "@ltd/j-toml": "^1.38.0",
"@modrinth/assets": "workspace:*", "@modrinth/assets": "workspace:*",
"@modrinth/blog": "workspace:*",
"@modrinth/moderation": "workspace:*",
"@modrinth/ui": "workspace:*", "@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*", "@modrinth/utils": "workspace:*",
"@pinia/nuxt": "^0.5.1", "@pinia/nuxt": "^0.5.1",
"@types/three": "^0.172.0",
"@vintl/vintl": "^4.4.1", "@vintl/vintl": "^4.4.1",
"@vueuse/core": "^11.1.0", "@vueuse/core": "^11.1.0",
"ace-builds": "^1.36.2", "ace-builds": "^1.36.2",
@@ -59,10 +56,10 @@
"markdown-it": "14.1.0", "markdown-it": "14.1.0",
"pathe": "^1.1.2", "pathe": "^1.1.2",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"prettier": "^3.6.2",
"qrcode.vue": "^3.4.0", "qrcode.vue": "^3.4.0",
"semver": "^7.5.4", "semver": "^7.5.4",
"three": "^0.172.0", "three": "^0.172.0",
"@types/three": "^0.172.0",
"vue-multiselect": "3.0.0-alpha.2", "vue-multiselect": "3.0.0-alpha.2",
"vue-typed-virtual-list": "^1.0.10", "vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4", "vue3-ace-editor": "^2.2.4",

View File

@@ -197,13 +197,13 @@
} }
> :where( > :where(
input + *, input + *,
.input-group + *, .input-group + *,
.textarea-wrapper + *, .textarea-wrapper + *,
.chips + *, .chips + *,
.resizable-textarea-wrapper + *, .resizable-textarea-wrapper + *,
.input-div + * .input-div + *
) { ) {
&:not(:empty) { &:not(:empty) {
margin-block-start: var(--spacing-card-md); margin-block-start: var(--spacing-card-md);
} }

View File

@@ -115,12 +115,10 @@ html {
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15); --shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15);
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2); --shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: --shadow-raised: 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12), 1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09); 4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
--shadow-floating: --shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px; hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px; --shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
@@ -152,8 +150,8 @@ html {
rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0.35) 0%,
rgba(255, 255, 255, 0.2695) 100% rgba(255, 255, 255, 0.2695) 100%
); );
--landing-blob-shadow: --landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16),
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(255, 255, 255, 0.45); inset 2px 2px 64px rgba(255, 255, 255, 0.45);
--landing-card-bg: rgba(255, 255, 255, 0.8); --landing-card-bg: rgba(255, 255, 255, 0.8);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16); --landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
@@ -253,15 +251,13 @@ html {
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2); --shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1); --shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
--shadow-floating: --shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px; hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px; --shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp"); --landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp");
--landing-maze-gradient-bg: --landing-maze-gradient-bg: linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
url("https://cdn.modrinth.com/landing-new/landing-lower.webp"); url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%); --landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
@@ -288,8 +284,7 @@ html {
rgba(44, 48, 79, 0.35) 0%, rgba(44, 48, 79, 0.35) 0%,
rgba(32, 35, 50, 0.2695) 100% rgba(32, 35, 50, 0.2695) 100%
); );
--landing-blob-shadow: --landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
--landing-card-bg: rgba(59, 63, 85, 0.15); --landing-card-bg: rgba(59, 63, 85, 0.15);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16); --landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
@@ -365,9 +360,8 @@ body {
// Defaults // Defaults
background-color: var(--color-bg); background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);
--font-standard: --font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; --mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
font-family: var(--font-standard); font-family: var(--font-standard);
font-size: 16px; font-size: 16px;

View File

@@ -1,20 +1,15 @@
<template> <template>
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised"> <div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
<nuxt-link <div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
to="/servers" <p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]" <nuxt-link to="/plus" class="mt-auto items-center gap-1 text-purple hover:underline">
> <span>
<img Support creators and Modrinth ad-free with
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp" <span class="font-bold">Modrinth+</span>
alt="Host your next server with Modrinth Servers" </span>
class="light-image hidden rounded-[inherit]" <ChevronRightIcon class="relative top-[3px] h-5 w-5" />
/> </nuxt-link>
<img </div>
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp"
alt="Host your next server with Modrinth Servers"
class="dark-image rounded-[inherit]"
/>
</nuxt-link>
<div <div
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised" class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
> >
@@ -23,6 +18,8 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { ChevronRightIcon } from "@modrinth/assets";
useHead({ useHead({
script: [ script: [
// { // {
@@ -140,16 +137,3 @@ iframe[id^="google_ads_iframe"] {
} }
} }
</style> </style>
<style lang="scss" scoped>
.light,
.light-mode {
.dark-image {
display: none;
}
.light-image {
display: block;
}
}
</style>

View File

@@ -9,7 +9,7 @@
</h1> </h1>
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background"> <ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
<button v-tooltip="`Exit moderation`" @click="exitModeration"> <button v-tooltip="`Exit moderation`" @click="exitModeration">
<XIcon /> <CrossIcon />
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled circular> <ButtonStyled circular>
@@ -306,7 +306,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ButtonStyled v-if="!done"> <ButtonStyled v-if="!done">
<button aria-label="Skip" @click="goToNextProject"> <button aria-label="Skip" @click="goToNextProject">
<XIcon aria-hidden="true" /> <ExitIcon aria-hidden="true" />
<template v-if="futureProjects.length > 0">Skip</template> <template v-if="futureProjects.length > 0">Skip</template>
<template v-else>Exit</template> <template v-else>Exit</template>
</button> </button>
@@ -335,7 +335,7 @@
<div class="joined-buttons"> <div class="joined-buttons">
<ButtonStyled color="red"> <ButtonStyled color="red">
<button @click="sendMessage('rejected')"> <button @click="sendMessage('rejected')">
<XIcon aria-hidden="true" /> Reject <CrossIcon aria-hidden="true" /> Reject
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled color="red"> <ButtonStyled color="red">
@@ -373,8 +373,9 @@ import {
UpdatedIcon, UpdatedIcon,
CheckIcon, CheckIcon,
DropdownIcon, DropdownIcon,
XIcon as CrossIcon,
EyeOffIcon, EyeOffIcon,
XIcon, ExitIcon,
ScaleIcon, ScaleIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { ButtonStyled, MarkdownEditor, OverflowMenu, Collapsible } from "@modrinth/ui"; import { ButtonStyled, MarkdownEditor, OverflowMenu, Collapsible } from "@modrinth/ui";

View File

@@ -1,51 +0,0 @@
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { MailIcon, CheckIcon } from "@modrinth/assets";
import { ref, watchEffect } from "vue";
import { useBaseFetch } from "~/composables/fetch.js";
const auth = await useAuth();
const showSubscriptionConfirmation = ref(false);
const subscribed = ref(false);
async function checkSubscribed() {
if (auth.value?.user) {
try {
const { data } = await useBaseFetch("auth/email/subscribe", {
method: "GET",
});
subscribed.value = data?.subscribed || false;
} catch {
subscribed.value = false;
}
}
}
watchEffect(() => {
checkSubscribed();
});
async function subscribe() {
try {
await useBaseFetch("auth/email/subscribe", {
method: "POST",
});
showSubscriptionConfirmation.value = true;
} catch {
} finally {
setTimeout(() => {
showSubscriptionConfirmation.value = false;
subscribed.value = true;
}, 2500);
}
}
</script>
<template>
<ButtonStyled v-if="auth?.user && !subscribed" color="brand" type="outlined">
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
<template v-else> <CheckIcon /> Subscribed! </template>
</button>
</ButtonStyled>
</template>

View File

@@ -1,10 +1,7 @@
<template> <template>
<div <div
class="vue-notification-group experimental-styles-within" class="vue-notification-group experimental-styles-within"
:class="{ :class="{ 'intercom-present': isIntercomPresent }"
'intercom-present': isIntercomPresent,
rightwards: moveNotificationsRight,
}"
> >
<transition-group name="notifs"> <transition-group name="notifs">
<div <div
@@ -85,7 +82,6 @@ import {
CopyIcon, CopyIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
const notifications = useNotifications(); const notifications = useNotifications();
const { isVisible: moveNotificationsRight } = useNotificationRightwards();
const isIntercomPresent = ref(false); const isIntercomPresent = ref(false);
@@ -164,15 +160,6 @@ function copyToClipboard(notif) {
bottom: 5rem; bottom: 5rem;
} }
&.rightwards {
right: unset !important;
left: 1.5rem;
@media screen and (max-width: 500px) {
left: 0.75rem;
}
}
.vue-notification-wrapper { .vue-notification-wrapper {
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;

View File

@@ -1,86 +0,0 @@
<template>
<div class="flex gap-2">
<ButtonStyled circular>
<a
v-tooltip="`Share on Bluesky`"
:href="`https://bsky.app/intent/compose?text=${encodedUrl}`"
target="_blank"
>
<BlueskyIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="`Share on Mastodon`"
:href="`https://tootpick.org/#text=${encodedUrl}`"
target="_blank"
>
<MastodonIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="`Share on X`"
:href="`https://www.x.com/intent/post?url=${encodedUrl}`"
target="_blank"
>
<TwitterIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="`Share via email`"
:href="`mailto:${encodedTitle ? `?subject=${encodedTitle}&` : `?`}body=${encodedUrl}`"
target="_blank"
>
<MailIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<button
v-tooltip="copied ? `Copied to clipboard` : `Copy link`"
:disabled="copied"
class="relative grid place-items-center overflow-hidden"
@click="copyToClipboard(url)"
>
<CheckIcon
class="absolute transition-all ease-in-out"
:class="copied ? 'translate-y-0' : 'translate-y-7'"
/>
<LinkIcon
class="absolute transition-all ease-in-out"
:class="copied ? '-translate-y-7' : 'translate-y-0'"
/>
</button>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import {
BlueskyIcon,
CheckIcon,
LinkIcon,
MailIcon,
MastodonIcon,
TwitterIcon,
} from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
const props = defineProps<{
title?: string;
url: string;
}>();
const copied = ref(false);
const encodedUrl = computed(() => encodeURIComponent(props.url));
const encodedTitle = computed(() => (props.title ? encodeURIComponent(props.title) : undefined));
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 3000);
}
</script>

View File

@@ -1,116 +0,0 @@
<template>
<NewModal ref="modal" header="Moderation shortcuts" :closable="true">
<div>
<div class="keybinds-sections">
<div class="grid grid-cols-2 gap-x-12 gap-y-3">
<div
v-for="keybind in keybinds"
:key="keybind.id"
class="keybind-item flex items-center justify-between gap-4"
:class="{
'col-span-2': keybinds.length % 2 === 1 && keybinds[keybinds.length - 1] === keybind,
}"
>
<span class="text-sm text-secondary">{{ keybind.description }}</span>
<div class="flex items-center gap-1">
<kbd
v-for="(key, index) in parseKeybindDisplay(keybind.keybind)"
:key="`${keybind.id}-key-${index}`"
class="keybind-key"
>
{{ key }}
</kbd>
</div>
</div>
</div>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
const modal = ref<InstanceType<typeof NewModal>>();
function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
const keybinds = Array.isArray(keybind) ? keybind : [keybind];
const normalized = keybinds[0];
const def = normalizeKeybind(normalized);
const keys = [];
if (def.ctrl || def.meta) {
keys.push(isMac() ? "CMD" : "CTRL");
}
if (def.shift) keys.push("SHIFT");
if (def.alt) keys.push("ALT");
const mainKey = def.key
.replace("ArrowLeft", "←")
.replace("ArrowRight", "→")
.replace("ArrowUp", "↑")
.replace("ArrowDown", "↓")
.replace("Enter", "↵")
.replace("Space", "SPACE")
.replace("Escape", "ESC")
.toUpperCase();
keys.push(mainKey);
return keys;
}
function isMac() {
return navigator.platform.toUpperCase().indexOf("MAC") >= 0;
}
function show(event?: MouseEvent) {
modal.value?.show(event);
}
function hide() {
modal.value?.hide();
}
defineExpose({
show,
hide,
});
</script>
<style scoped lang="scss">
.keybind-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
padding: 0.25rem 0.5rem;
background-color: var(--color-bg);
border: 1px solid var(--color-divider);
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-contrast);
+ .keybind-key {
margin-left: 0.25rem;
}
}
.keybind-item {
min-height: 2rem;
}
@media (max-width: 768px) {
.keybinds-sections {
.grid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
}
</style>

View File

@@ -1,422 +0,0 @@
<template>
<div>
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
Modpack permissions ({{ Math.min(modPackData.length, currentIndex + 1) }} /
{{ modPackData.length }})
</h2>
<div v-if="!modPackData">Loading data...</div>
<div v-else-if="modPackData.length === 0">
<p>All permissions obtained. You may skip this step!</p>
</div>
<div v-else-if="!modPackData[currentIndex]">
<p>All permission checks complete!</p>
</div>
<div v-else>
<div v-if="modPackData[currentIndex].type === 'unknown'">
<p>What is the approval type of {{ modPackData[currentIndex].file_name }}?</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in fileApprovalTypes"
:key="index"
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
@click="setStatus(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
<div v-if="modPackData[currentIndex].status !== 'unidentified'" class="flex flex-col gap-1">
<label for="proof">
<span class="label__title">Proof</span>
</label>
<input
id="proof"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).proof"
type="text"
autocomplete="off"
placeholder="Enter proof of status..."
@input="persistAll()"
/>
<label for="link">
<span class="label__title">Link</span>
</label>
<input
id="link"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).url"
type="text"
autocomplete="off"
placeholder="Enter link of project..."
@input="persistAll()"
/>
<label for="title">
<span class="label__title">Title</span>
</label>
<input
id="title"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).title"
type="text"
autocomplete="off"
placeholder="Enter title of project..."
@input="persistAll()"
/>
</div>
</div>
<div v-else-if="modPackData[currentIndex].type === 'flame'">
<p>
What is the approval type of {{ modPackData[currentIndex].title }} (<a
:href="modPackData[currentIndex].url"
target="_blank"
class="text-link"
>{{ modPackData[currentIndex].url }}</a
>)?
</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in fileApprovalTypes"
:key="index"
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
@click="setStatus(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
</div>
<div
v-if="
['unidentified', 'no', 'with-attribution'].includes(
modPackData[currentIndex].status || '',
)
"
>
<p v-if="modPackData[currentIndex].status === 'unidentified'">
Does this project provide identification and permission for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<p v-else-if="modPackData[currentIndex].status === 'with-attribution'">
Does this project provide attribution for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<p v-else>
Does this project provide proof of permission for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in filePermissionTypes"
:key="index"
:color="modPackData[currentIndex].approved === option.id ? 'brand' : 'standard'"
@click="setApproval(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="mt-4 flex gap-2">
<ButtonStyled>
<button :disabled="currentIndex <= 0" @click="goToPrevious">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
</ButtonStyled>
<ButtonStyled v-if="modPackData && currentIndex < modPackData.length" color="blue">
<button :disabled="!canGoNext" @click="goToNext">
<RightArrowIcon aria-hidden="true" />
{{ currentIndex + 1 >= modPackData.length ? "Complete" : "Next" }}
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { LeftArrowIcon, RightArrowIcon } from "@modrinth/assets";
import type {
ModerationJudgements,
ModerationModpackItem,
ModerationModpackResponse,
ModerationUnknownModpackItem,
ModerationFlameModpackItem,
ModerationModpackPermissionApprovalType,
ModerationPermissionType,
} from "@modrinth/utils";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted } from "vue";
import { useLocalStorage } from "@vueuse/core";
const props = defineProps<{
projectId: string;
modelValue?: ModerationJudgements;
}>();
const emit = defineEmits<{
complete: [];
"update:modelValue": [judgements: ModerationJudgements];
}>();
const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
`modpack-permissions-${props.projectId}`,
null,
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : null),
write: (v: any) => JSON.stringify(v),
},
},
);
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
const modPackData = ref<ModerationModpackItem[] | null>(null);
const currentIndex = ref(0);
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
{
id: "yes",
name: "Yes",
},
{
id: "with-attribution-and-source",
name: "With attribution and source",
},
{
id: "with-attribution",
name: "With attribution",
},
{
id: "no",
name: "No",
},
{
id: "permanent-no",
name: "Permanent no",
},
{
id: "unidentified",
name: "Unidentified",
},
];
const filePermissionTypes: ModerationPermissionType[] = [
{ id: "yes", name: "Yes" },
{ id: "no", name: "No" },
];
function persistAll() {
persistedModPackData.value = modPackData.value;
persistedIndex.value = currentIndex.value;
}
watch(
modPackData,
(newValue) => {
persistedModPackData.value = newValue;
},
{ deep: true },
);
watch(currentIndex, (newValue) => {
persistedIndex.value = newValue;
});
function loadPersistedData(): void {
if (persistedModPackData.value) {
modPackData.value = persistedModPackData.value;
}
currentIndex.value = persistedIndex.value;
}
function clearPersistedData(): void {
persistedModPackData.value = null;
persistedIndex.value = 0;
}
async function fetchModPackData(): Promise<void> {
try {
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
internal: true,
})) as ModerationModpackResponse;
const sortedData: ModerationModpackItem[] = [
...Object.entries(data.unknown_files || {})
.map(
([sha1, fileName]): ModerationUnknownModpackItem => ({
sha1,
file_name: fileName,
type: "unknown",
status: null,
approved: null,
proof: "",
url: "",
title: "",
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
...Object.entries(data.flame_files || {})
.map(
([sha1, info]): ModerationFlameModpackItem => ({
sha1,
file_name: info.file_name,
type: "flame",
status: null,
approved: null,
id: info.id,
title: info.title || info.file_name,
url: info.url || `https://www.curseforge.com/minecraft/mc-mods/${info.id}`,
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
];
if (modPackData.value) {
const existingMap = new Map(modPackData.value.map((item) => [item.sha1, item]));
sortedData.forEach((item) => {
const existing = existingMap.get(item.sha1);
if (existing) {
Object.assign(item, {
status: existing.status,
approved: existing.approved,
...(item.type === "unknown" && {
proof: (existing as ModerationUnknownModpackItem).proof || "",
url: (existing as ModerationUnknownModpackItem).url || "",
title: (existing as ModerationUnknownModpackItem).title || "",
}),
...(item.type === "flame" && {
url: (existing as ModerationFlameModpackItem).url || item.url,
title: (existing as ModerationFlameModpackItem).title || item.title,
}),
});
}
});
}
modPackData.value = sortedData;
persistAll();
} catch (error) {
console.error("Failed to fetch modpack data:", error);
modPackData.value = [];
persistAll();
}
}
function goToPrevious(): void {
if (currentIndex.value > 0) {
currentIndex.value--;
persistAll();
}
}
function goToNext(): void {
if (modPackData.value && currentIndex.value < modPackData.value.length) {
currentIndex.value++;
if (currentIndex.value >= modPackData.value.length) {
const judgements = getJudgements();
emit("update:modelValue", judgements);
emit("complete");
clearPersistedData();
} else {
persistAll();
}
}
}
function setStatus(index: number, status: ModerationModpackPermissionApprovalType["id"]): void {
if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].status = status;
modPackData.value[index].approved = null;
persistAll();
emit("update:modelValue", getJudgements());
}
}
function setApproval(index: number, approved: ModerationPermissionType["id"]): void {
if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].approved = approved;
persistAll();
emit("update:modelValue", getJudgements());
}
}
const canGoNext = computed(() => {
if (!modPackData.value || !modPackData.value[currentIndex.value]) return false;
const current = modPackData.value[currentIndex.value];
return current.status !== null;
});
function getJudgements(): ModerationJudgements {
if (!modPackData.value) return {};
const judgements: ModerationJudgements = {};
modPackData.value.forEach((item) => {
if (item.type === "flame") {
judgements[item.sha1] = {
type: "flame",
id: item.id,
status: item.status,
link: item.url,
title: item.title,
file_name: item.file_name,
};
} else if (item.type === "unknown") {
judgements[item.sha1] = {
type: "unknown",
status: item.status,
proof: item.proof,
link: item.url,
title: item.title,
file_name: item.file_name,
};
}
});
return judgements;
}
onMounted(() => {
loadPersistedData();
if (!modPackData.value) {
fetchModPackData();
}
});
watch(
() => props.projectId,
() => {
clearPersistedData();
loadPersistedData();
if (!modPackData.value) {
fetchModPackData();
}
},
);
</script>
<style scoped>
.input-group {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.modpack-buttons {
margin-top: 1rem;
}
</style>

View File

@@ -1,49 +0,0 @@
<template>
<div class="mx-2 p-4 !py-8 sm:mx-8 sm:p-32">
<div class="my-8 flex items-center justify-between">
<h2 class="m-0 mx-auto text-3xl font-extrabold sm:text-4xl">Latest news from Modrinth</h2>
</div>
<div v-if="latestArticles" class="grid grid-cols-[repeat(auto-fit,minmax(250px,1fr))] gap-4">
<div
v-for="(article, index) in latestArticles"
:key="article.slug"
:class="{ 'max-xl:hidden': index === 2 }"
>
<NewsArticleCard :article="article" />
</div>
</div>
<div class="mx-2 my-8 flex w-full items-center justify-center">
<ButtonStyled color="brand" size="large">
<nuxt-link to="/news">
<NewspaperIcon />
View all news
</nuxt-link>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { NewspaperIcon } from "@modrinth/assets";
import { articles as rawArticles } from "@modrinth/blog";
import { ButtonStyled, NewsArticleCard } from "@modrinth/ui";
import { ref, computed } from "vue";
const articles = ref(
rawArticles
.map((article) => ({
...article,
path: `/news/article/${article.slug}/`,
thumbnail: article.thumbnail
? `/news/article/${article.slug}/thumbnail.webp`
: `/news/default.webp`,
title: article.title,
summary: article.summary,
date: article.date,
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
);
const latestArticles = computed(() => articles.value.slice(0, 3));
</script>

View File

@@ -172,7 +172,6 @@ const flags = useFeatureFlags();
.markdown-body { .markdown-body {
grid-area: body; grid-area: body;
max-width: 100%;
} }
.reporter-info { .reporter-info {

View File

@@ -2,7 +2,7 @@
<NewModal ref="modModal" :header="`Editing ${type.toLocaleLowerCase()} version`"> <NewModal ref="modModal" :header="`Editing ${type.toLocaleLowerCase()} version`">
<template #title> <template #title>
<div class="flex min-w-full items-center gap-2 md:w-[calc(420px-5.5rem)]"> <div class="flex min-w-full items-center gap-2 md:w-[calc(420px-5.5rem)]">
<Avatar :src="modDetails?.icon_url" size="48px" :alt="`${modDetails?.name} Icon`" /> <UiAvatar :src="modDetails?.icon_url" size="48px" :alt="`${modDetails?.name} Icon`" />
<span class="truncate text-xl font-extrabold text-contrast">{{ modDetails?.name }}</span> <span class="truncate text-xl font-extrabold text-contrast">{{ modDetails?.name }}</span>
</div> </div>
</template> </template>
@@ -31,9 +31,9 @@
class="flex cursor-pointer items-center gap-1 bg-transparent p-0" class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
@click=" @click="
versionFilter && versionFilter &&
(unlockFilterAccordion.isOpen (unlockFilterAccordion.isOpen
? unlockFilterAccordion.close() ? unlockFilterAccordion.close()
: unlockFilterAccordion.open()) : unlockFilterAccordion.open())
" "
> >
<TagItem <TagItem
@@ -185,7 +185,7 @@
Something went wrong trying to load versions for this {{ type.toLocaleLowerCase() }}. Something went wrong trying to load versions for this {{ type.toLocaleLowerCase() }}.
Please try again later or contact support if the issue persists. Please try again later or contact support if the issue persists.
</span> </span>
<CopyCode class="!mt-2 !break-all" :text="versionsError" /> <LazyUiCopyCode class="!mt-2 !break-all" :text="versionsError" />
</div> </div>
</Admonition> </Admonition>
@@ -236,7 +236,7 @@ import {
GameIcon, GameIcon,
ExternalIcon, ExternalIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { Admonition, Avatar, ButtonStyled, CopyCode, NewModal } from "@modrinth/ui"; import { Admonition, ButtonStyled, NewModal } from "@modrinth/ui";
import TagItem from "@modrinth/ui/src/components/base/TagItem.vue"; import TagItem from "@modrinth/ui/src/components/base/TagItem.vue";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { formatCategory, formatVersionsForDisplay, type Mod, type Version } from "@modrinth/utils"; import { formatCategory, formatVersionsForDisplay, type Mod, type Version } from "@modrinth/utils";

View File

@@ -1,176 +1,124 @@
<template> <template>
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow"> <NewModal ref="mrpackModal" header="Uploading mrpack" @hide="onHide" @show="onShow">
<div class="flex flex-col gap-4 md:w-[600px]"> <div class="flex flex-col gap-4 md:w-[600px]">
<Transition <p
enter-active-class="transition-all duration-300 ease-out" v-if="isMrpackModalSecondPhase"
enter-from-class="opacity-0 max-h-0" :style="{
enter-to-class="opacity-100 max-h-20" lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
leave-active-class="transition-all duration-200 ease-in" marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
leave-from-class="opacity-100 max-h-20" marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
leave-to-class="opacity-0 max-h-0" }"
> >
<div v-if="isLoading" class="w-full"> This will reinstall your server and erase all data. You may want to back up your server
<div class="mb-2 flex justify-between text-sm"> before proceeding. Are you sure you want to continue?
<Transition name="phrase-fade" mode="out-in"> </p>
<span :key="currentPhrase" class="text-lg font-medium text-contrast">{{ <div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
currentPhrase <div class="mx-auto flex flex-row items-center gap-4">
}}</span> <div
</Transition> class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
<div class="flex flex-col items-end">
<span class="text-secondary">{{ Math.round(uploadProgress) }}%</span>
<span class="text-xs text-secondary"
>{{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }}</span
>
</div>
</div>
<div class="h-2 w-full rounded-full bg-divider">
<div
class="h-2 animate-pulse rounded-full bg-brand transition-all duration-300 ease-out"
:style="{ width: `${uploadProgress}%` }"
></div>
</div>
</div>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="!isLoading" class="flex flex-col gap-4">
<p
v-if="isMrpackModalSecondPhase"
:style="{
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
}"
> >
This will reinstall your server and erase all data. You may want to back up your server <UploadIcon class="size-10" />
before proceeding. Are you sure you want to continue?
</p>
<div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
<div class="mx-auto flex flex-row items-center gap-4">
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
>
<UploadIcon class="size-10" />
</div>
<ArrowBigRightDashIcon class="size-10" />
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
>
<ServerIcon class="size-10" />
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
<input
type="file"
accept=".mrpack"
class=""
:disabled="isLoading"
@change="uploadMrpack"
/>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<input
id="hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration
files, then reinstalls it with the selected version.
</div>
<div class="font-bold">
This does not affect your backups, which are stored off-site.
</div>
</div>
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
</div> </div>
<div class="mt-4 flex justify-start gap-4"> <svg
<ButtonStyled :color="isDangerous ? 'red' : 'brand'"> xmlns="http://www.w3.org/2000/svg"
<button width="24"
v-tooltip="backupInProgress ? backupInProgress.tooltip : undefined" height="24"
:disabled="canInstall || !!backupInProgress" viewBox="0 0 24 24"
@click="handleReinstall" fill="none"
> stroke="currentColor"
<RightArrowIcon /> stroke-width="2"
{{ stroke-linecap="round"
isMrpackModalSecondPhase stroke-linejoin="round"
? "Erase and install" class="size-10"
: loadingServerCheck >
? "Loading..." <path d="M5 9v6" />
: isDangerous <path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
? "Erase and install" </svg>
: "Install" <div
}} class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
</button> >
</ButtonStyled> <ServerIcon class="size-10" />
<ButtonStyled>
<button
:disabled="isLoading"
@click="
() => {
if (isMrpackModalSecondPhase) {
isMrpackModalSecondPhase = false;
} else {
hide();
}
}
"
>
<XIcon />
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }}
</button>
</ButtonStyled>
</div> </div>
</div> </div>
</Transition> <div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
<input
type="file"
accept=".mrpack"
class=""
:disabled="isLoading"
@change="uploadMrpack"
/>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<input
id="hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration files,
then reinstalls it with the selected version.
</div>
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
</div>
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="canInstall || backupInProgress"
@click="handleReinstall"
>
<RightArrowIcon />
{{
isMrpackModalSecondPhase
? "Erase and install"
: loadingServerCheck
? "Loading..."
: isDangerous
? "Erase and install"
: "Install"
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="isLoading"
@click="
() => {
if (isMrpackModalSecondPhase) {
isMrpackModalSecondPhase = false;
} else {
hide();
}
}
"
>
<XIcon />
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }}
</button>
</ButtonStyled>
</div>
</div> </div>
</NewModal> </NewModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui"; import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
UploadIcon, import { ModrinthServersFetchError } from "@modrinth/utils";
RightArrowIcon,
XIcon,
ServerIcon,
ArrowBigRightDashIcon,
} from "@modrinth/assets";
import { formatBytes, ModrinthServersFetchError } from "@modrinth/utils";
import { onMounted, onUnmounted } from "vue";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue"; import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import type { ModrinthServer } from "~/composables/servers/modrinth-servers"; import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (isLoading.value) {
event.preventDefault();
return "Upload in progress. Are you sure you want to leave?";
}
};
onMounted(() => {
window.addEventListener("beforeunload", handleBeforeUnload);
});
onUnmounted(() => {
window.removeEventListener("beforeunload", handleBeforeUnload);
});
const props = defineProps<{ const props = defineProps<{
server: ModrinthServer; server: ModrinthServer;
@@ -187,49 +135,6 @@ const hardReset = ref(false);
const isLoading = ref(false); const isLoading = ref(false);
const loadingServerCheck = ref(false); const loadingServerCheck = ref(false);
const mrpackFile = ref<File | null>(null); const mrpackFile = ref<File | null>(null);
const uploadProgress = ref(0);
const uploadedBytes = ref(0);
const totalBytes = ref(0);
const uploadPhrases = [
"Removing Herobrine...",
"Feeding parrots...",
"Teaching villagers new trades...",
"Convincing creepers to be friendly...",
"Polishing diamonds...",
"Training wolves to fetch...",
"Building pixel art...",
"Explaining redstone to beginners...",
"Collecting all the cats...",
"Negotiating with endermen...",
"Planting suspicious stew ingredients...",
"Calibrating TNT blast radius...",
"Teaching chickens to fly...",
"Sorting inventory alphabetically...",
"Convincing iron golems to smile...",
];
const currentPhrase = ref("Uploading...");
let phraseInterval: NodeJS.Timeout | null = null;
const usedPhrases = ref(new Set<number>());
const getNextPhrase = () => {
if (usedPhrases.value.size >= uploadPhrases.length) {
const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value);
usedPhrases.value.clear();
if (currentPhraseIndex !== -1) {
usedPhrases.value.add(currentPhraseIndex);
}
}
const availableIndices = uploadPhrases
.map((_, index) => index)
.filter((index) => !usedPhrases.value.has(index));
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)];
usedPhrases.value.add(randomIndex);
return uploadPhrases[randomIndex];
};
const isDangerous = computed(() => hardReset.value); const isDangerous = computed(() => hardReset.value);
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value); const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
@@ -248,46 +153,18 @@ const handleReinstall = async () => {
return; return;
} }
if (!mrpackFile.value) {
addNotification({
group: "server",
title: "No file selected",
text: "Choose a .mrpack file before installing.",
type: "error",
});
return;
}
isLoading.value = true; isLoading.value = true;
uploadProgress.value = 0;
uploadProgress.value = 0;
uploadedBytes.value = 0;
totalBytes.value = mrpackFile.value.size;
currentPhrase.value = getNextPhrase();
phraseInterval = setInterval(() => {
currentPhrase.value = getNextPhrase();
}, 4500);
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
mrpackFile.value,
hardReset.value,
);
onProgress(({ loaded, total, progress }) => {
uploadProgress.value = progress;
uploadedBytes.value = loaded;
totalBytes.value = total;
if (phraseInterval && progress >= 100) {
clearInterval(phraseInterval);
phraseInterval = null;
currentPhrase.value = "Installing modpack...";
}
});
try { try {
await promise; if (!mrpackFile.value) {
throw new Error("No mrpack file selected");
}
const mrpack = new File([mrpackFile.value], mrpackFile.value.name, {
type: mrpackFile.value.type,
});
await props.server.general?.reinstallFromMrpack(mrpack, hardReset.value);
emit("reinstall", { emit("reinstall", {
loader: "mrpack", loader: "mrpack",
@@ -299,44 +176,36 @@ const handleReinstall = async () => {
window.scrollTo(0, 0); window.scrollTo(0, 0);
hide(); hide();
} catch (error) { } catch (error) {
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) { if (error instanceof ModrinthServersFetchError && error?.statusCode === 429) {
addNotification({ addNotification({
group: "server", group: "server",
title: "Cannot upload and install modpack to server", title: "Cannot reinstall server",
text: "You are being rate limited. Please try again later.", text: "You are being rate limited. Please try again later.",
type: "error", type: "error",
}); });
} else { } else {
addNotification({ addNotification({
group: "server", group: "server",
title: "Modpack upload and install failed", title: "Reinstall Failed",
text: "An unexpected error occurred while uploading/installing. Please try again later.", text: "An unexpected error occurred while reinstalling. Please try again later.",
type: "error", type: "error",
}); });
} }
} finally { } finally {
isLoading.value = false; isLoading.value = false;
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
}
} }
}; };
const onShow = () => { const onShow = () => {
hardReset.value = false; hardReset.value = false;
isMrpackModalSecondPhase.value = false; isMrpackModalSecondPhase.value = false;
loadingServerCheck.value = false; loadingServerCheck.value = false;
isLoading.value = false; isLoading.value = false;
mrpackFile.value = null; mrpackFile.value = null;
uploadProgress.value = 0; };
uploadedBytes.value = 0;
totalBytes.value = 0; const onHide = () => {
currentPhrase.value = "Uploading..."; onShow();
usedPhrases.value.clear();
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
}
}; };
const show = () => mrpackModal.value?.show(); const show = () => mrpackModal.value?.show();
@@ -349,14 +218,4 @@ defineExpose({ show, hide });
.stylized-toggle:checked::after { .stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important; background: var(--color-accent-contrast) !important;
} }
.phrase-fade-enter-active,
.phrase-fade-leave-active {
transition: opacity 0.3s ease;
}
.phrase-fade-enter-from,
.phrase-fade-leave-to {
opacity: 0;
}
</style> </style>

View File

@@ -33,7 +33,7 @@
v-if="projectData?.title" v-if="projectData?.title"
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary" class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
> >
<Avatar <UiAvatar
:src="iconUrl" :src="iconUrl"
no-shadow no-shadow
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px" style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
@@ -74,7 +74,7 @@
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please <UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
update your billing information or contact Modrinth Support for more information. update your billing information or contact Modrinth Support for more information.
</div> </div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" /> <UiCopyCode :text="`${props.server_id}`" class="ml-auto" />
</div> </div>
</NuxtLink> </NuxtLink>
</template> </template>
@@ -83,7 +83,6 @@
import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets"; import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets";
import type { Project, Server } from "@modrinth/utils"; import type { Project, Server } from "@modrinth/utils";
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts"; import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
import { Avatar, CopyCode } from "@modrinth/ui";
const props = defineProps<Partial<Server>>(); const props = defineProps<Partial<Server>>();

View File

@@ -50,7 +50,9 @@
</ClientOnly> </ClientOnly>
</div> </div>
</div> </div>
<nuxt-link
<component
:is="loading ? 'div' : 'NuxtLink'"
:to="loading ? undefined : `/servers/manage/${serverId}/files`" :to="loading ? undefined : `/servers/manage/${serverId}/files`"
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8" class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
:class="loading ? '' : 'transition-transform duration-100 hover:scale-105 active:scale-100'" :class="loading ? '' : 'transition-transform duration-100 hover:scale-105 active:scale-100'"
@@ -62,17 +64,16 @@
</div> </div>
<h3 class="text-base font-normal text-secondary">Storage usage</h3> <h3 class="text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" /> <FolderOpenIcon class="absolute right-10 top-10 size-8" />
</nuxt-link> </component>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, shallowRef } from "vue"; import { ref, computed, shallowRef } from "vue";
import { FolderOpenIcon, CpuIcon, DatabaseIcon, IssuesIcon } from "@modrinth/assets"; import { FolderOpenIcon, CPUIcon, DatabaseIcon, IssuesIcon } from "@modrinth/assets";
import { useStorage } from "@vueuse/core"; import { useStorage } from "@vueuse/core";
import type { Stats } from "@modrinth/utils"; import type { Stats } from "@modrinth/utils";
const flags = useFeatureFlags();
const route = useNativeRoute(); const route = useNativeRoute();
const serverId = route.params.id; const serverId = route.params.id;
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts")); const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
@@ -126,7 +127,7 @@ const metrics = computed(() => {
title: "CPU usage", title: "CPU usage",
value: "0.00%", value: "0.00%",
max: "100%", max: "100%",
icon: CpuIcon, icon: CPUIcon,
data: cpuData.value, data: cpuData.value,
showGraph: false, showGraph: false,
warning: null, warning: null,
@@ -157,21 +158,17 @@ const metrics = computed(() => {
title: "CPU usage", title: "CPU usage",
value: `${cpuPercent.toFixed(2)}%`, value: `${cpuPercent.toFixed(2)}%`,
max: "100%", max: "100%",
icon: CpuIcon, icon: CPUIcon,
data: cpuData.value, data: cpuData.value,
showGraph: true, showGraph: true,
warning: cpuPercent >= 90 ? "CPU usage is very high" : null, warning: cpuPercent >= 90 ? "CPU usage is very high" : null,
}, },
{ {
title: "Memory usage", title: "Memory usage",
value: value: userPreferences.value.ramAsNumber
userPreferences.value.ramAsNumber || flags.developerMode ? formatBytes(stats.value.ram_usage_bytes)
? formatBytes(stats.value.ram_usage_bytes) : `${ramPercent.toFixed(2)}%`,
: `${ramPercent.toFixed(2)}%`, max: userPreferences.value.ramAsNumber ? formatBytes(stats.value.ram_total_bytes) : "100%",
max:
userPreferences.value.ramAsNumber || flags.developerMode
? formatBytes(stats.value.ram_total_bytes)
: "100%",
icon: DatabaseIcon, icon: DatabaseIcon,
data: ramData.value, data: ramData.value,
showGraph: true, showGraph: true,

View File

@@ -1,26 +1,18 @@
const formatters = new WeakMap<object, Intl.NumberFormat>(); import { createFormatter, type Formatter } from "@vintl/compact-number";
import type { IntlController } from "@vintl/vintl/controller";
export function useCompactNumber(truncate = false, fractionDigits = 2, locale?: string) { const formatters = new WeakMap<IntlController<any>, Formatter>();
const context = {};
let formatter = formatters.get(context); export function useCompactNumber(): Formatter {
const vintl = useVIntl();
if (!formatter) { let formatter = formatters.get(vintl);
formatter = new Intl.NumberFormat(locale, {
notation: "compact", if (formatter == null) {
maximumFractionDigits: fractionDigits, const formatterRef = computed(() => createFormatter(vintl.intl));
}); formatter = (value, options) => formatterRef.value(value, options);
formatters.set(context, formatter); formatters.set(vintl, formatter);
} }
function format(value: number): string { return formatter;
let formattedValue = value;
if (truncate) {
const scale = Math.pow(10, fractionDigits);
formattedValue = Math.floor(value * scale) / scale;
}
return formatter!.format(formattedValue);
}
return format;
} }

View File

@@ -31,7 +31,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
projectBackground: false, projectBackground: false,
searchBackground: false, searchBackground: false,
advancedDebugInfo: false, advancedDebugInfo: false,
showProjectPageDownloadModalServersPromo: false, showProjectPageDownloadModalServersPromo: true,
showProjectPageCreateServersTooltip: true, showProjectPageCreateServersTooltip: true,
showProjectPageQuickServerButton: false, showProjectPageQuickServerButton: false,
// advancedRendering: true, // advancedRendering: true,

View File

@@ -102,7 +102,7 @@ export class ModrinthServer {
try { try {
const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, { const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
override: auth, override: auth,
retry: 1, // Reduce retries for optional resources retry: false,
}); });
if (fileData instanceof Blob && import.meta.client) { if (fileData instanceof Blob && import.meta.client) {
@@ -124,14 +124,8 @@ export class ModrinthServer {
return dataURL; return dataURL;
} }
} catch (error) { } catch (error) {
if (error instanceof ModrinthServerError) { if (error instanceof ModrinthServerError && error.statusCode === 404) {
if (error.statusCode && error.statusCode >= 500) { if (iconUrl) {
console.debug("Service unavailable, skipping icon processing");
sharedImage.value = undefined;
return undefined;
}
if (error.statusCode === 404 && iconUrl) {
try { try {
const response = await fetch(iconUrl); const response = await fetch(iconUrl);
if (!response.ok) throw new Error("Failed to fetch icon"); if (!response.ok) throw new Error("Failed to fetch icon");
@@ -193,44 +187,6 @@ export class ModrinthServer {
return undefined; return undefined;
} }
async testNodeReachability(): Promise<boolean> {
if (!this.general?.node?.instance) {
console.warn("No node instance available for ping test");
return false;
}
const wsUrl = `wss://${this.general.node.instance}/pingtest`;
try {
return await new Promise((resolve) => {
const socket = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
socket.close();
resolve(false);
}, 5000);
socket.onopen = () => {
clearTimeout(timeout);
socket.send(performance.now().toString());
};
socket.onmessage = () => {
clearTimeout(timeout);
socket.close();
resolve(true);
};
socket.onerror = () => {
clearTimeout(timeout);
resolve(false);
};
});
} catch (error) {
console.error(`Failed to ping node ${wsUrl}:`, error);
return false;
}
}
async refresh( async refresh(
modules: ModuleName[] = [], modules: ModuleName[] = [],
options?: { options?: {
@@ -244,8 +200,6 @@ export class ModrinthServer {
: (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]); : (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]);
for (const module of modulesToRefresh) { for (const module of modulesToRefresh) {
this.errors[module] = undefined;
try { try {
switch (module) { switch (module) {
case "general": { case "general": {
@@ -296,7 +250,7 @@ export class ModrinthServer {
continue; continue;
} }
if (error.statusCode && error.statusCode >= 500) { if (error.statusCode === 503) {
console.debug(`Temporary ${module} unavailable:`, error.message); console.debug(`Temporary ${module} unavailable:`, error.message);
continue; continue;
} }

View File

@@ -22,49 +22,26 @@ export class FSModule extends ServerModule {
this.opsQueuedForModification = []; this.opsQueuedForModification = [];
} }
private async retryWithAuth<T>( private async retryWithAuth<T>(requestFn: () => Promise<T>): Promise<T> {
requestFn: () => Promise<T>,
ignoreFailure: boolean = false,
): Promise<T> {
try { try {
return await requestFn(); return await requestFn();
} catch (error) { } catch (error) {
if (error instanceof ModrinthServerError && error.statusCode === 401) { if (error instanceof ModrinthServerError && error.statusCode === 401) {
console.debug("Auth failed, refreshing JWT and retrying");
await this.fetch(); // Refresh auth await this.fetch(); // Refresh auth
return await requestFn(); return await requestFn();
} }
const available = await this.server.testNodeReachability();
if (!available && !ignoreFailure) {
this.server.moduleErrors.general = {
error: new ModrinthServerError(
"Unable to reach node. FS operation failed and subsequent ping test failed.",
500,
error as Error,
"fs",
),
timestamp: Date.now(),
};
}
throw error; throw error;
} }
} }
listDirContents( listDirContents(path: string, page: number, pageSize: number): Promise<DirectoryResponse> {
path: string,
page: number,
pageSize: number,
ignoreFailure: boolean = false,
): Promise<DirectoryResponse> {
return this.retryWithAuth(async () => { return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path); const encodedPath = encodeURIComponent(path);
return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, { return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
override: this.auth, override: this.auth,
retry: false, retry: false,
}); });
}, ignoreFailure); });
} }
createFileOrFolder(path: string, type: "file" | "directory"): Promise<void> { createFileOrFolder(path: string, type: "file" | "directory"): Promise<void> {
@@ -173,7 +150,7 @@ export class FSModule extends ServerModule {
}); });
} }
downloadFile(path: string, raw: boolean = false, ignoreFailure: boolean = false): Promise<any> { downloadFile(path: string, raw?: boolean): Promise<any> {
return this.retryWithAuth(async () => { return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path); const encodedPath = encodeURIComponent(path);
const fileData = await useServersFetch(`/download?path=${encodedPath}`, { const fileData = await useServersFetch(`/download?path=${encodedPath}`, {
@@ -184,7 +161,7 @@ export class FSModule extends ServerModule {
return raw ? fileData : await fileData.text(); return raw ? fileData : await fileData.text();
} }
return fileData; return fileData;
}, ignoreFailure); });
} }
extractFile( extractFile(

View File

@@ -46,18 +46,13 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined; data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined;
} }
try { const motd = await this.getMotd();
const motd = await this.getMotd(); if (motd === "A Minecraft Server") {
if (motd === "A Minecraft Server") { await this.setMotd(
await this.setMotd( `§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`,
`§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`, );
);
}
data.motd = motd;
} catch {
console.error("[Modrinth Servers] [General] Failed to fetch MOTD.");
data.motd = undefined;
} }
data.motd = motd;
// Copy data to this module // Copy data to this module
Object.assign(this, data); Object.assign(this, data);
@@ -103,67 +98,28 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
} }
} }
reinstallFromMrpack( async reinstallFromMrpack(mrpack: File, hardReset: boolean = false): Promise<void> {
mrpack: File,
hardReset: boolean = false,
): {
promise: Promise<void>;
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => void;
} {
const hardResetParam = hardReset ? "true" : "false"; const hardResetParam = hardReset ? "true" : "false";
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/reinstallFromMrpack`);
const progressSubject = new EventTarget(); const formData = new FormData();
formData.append("file", mrpack);
const uploadPromise = (async () => { const response = await fetch(
try { `https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`,
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/reinstallFromMrpack`); {
method: "POST",
headers: {
Authorization: `Bearer ${auth.token}`,
},
body: formData,
signal: AbortSignal.timeout(30 * 60 * 1000),
},
);
await new Promise<void>((resolve, reject) => { if (!response.ok) {
const xhr = new XMLHttpRequest(); throw new Error(`[pyroservers] native fetch err status: ${response.status}`);
}
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
progressSubject.dispatchEvent(
new CustomEvent("progress", {
detail: {
loaded: e.loaded,
total: e.total,
progress: (e.loaded / e.total) * 100,
},
}),
);
}
});
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300
? resolve()
: reject(new Error(`[pyroservers] XHR error status: ${xhr.status}`));
xhr.onerror = () => reject(new Error("[pyroservers] .mrpack upload failed"));
xhr.onabort = () => reject(new Error("[pyroservers] .mrpack upload cancelled"));
xhr.ontimeout = () => reject(new Error("[pyroservers] .mrpack upload timed out"));
xhr.timeout = 30 * 60 * 1000;
xhr.open("POST", `https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`);
xhr.setRequestHeader("Authorization", `Bearer ${auth.token}`);
const formData = new FormData();
formData.append("file", mrpack);
xhr.send(formData);
});
} catch (err) {
console.error("Error reinstalling from mrpack:", err);
throw err;
}
})();
return {
promise: uploadPromise,
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) =>
progressSubject.addEventListener("progress", ((e: CustomEvent) =>
cb(e.detail)) as EventListener),
};
} }
async suspend(status: boolean): Promise<void> { async suspend(status: boolean): Promise<void> {
@@ -183,7 +139,7 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
async getMotd(): Promise<string | undefined> { async getMotd(): Promise<string | undefined> {
try { try {
const props = await this.server.fs.downloadFile("/server.properties", false, true); const props = await this.server.fs.downloadFile("/server.properties");
if (props) { if (props) {
const lines = props.split("\n"); const lines = props.split("\n");
for (const line of lines) { for (const line of lines) {

View File

@@ -42,23 +42,6 @@ export async function useServersFetch<T>(
retry = method === "GET" ? 3 : 0, retry = method === "GET" ? 3 : 0,
} = options; } = options;
const circuitBreakerKey = `${module || "default"}_${path}`;
const failureCount = useState<number>(`fetch_failures_${circuitBreakerKey}`, () => 0);
const lastFailureTime = useState<number>(`last_failure_${circuitBreakerKey}`, () => 0);
const now = Date.now();
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
const error = new ModrinthServersFetchError(
"[Modrinth Servers] Circuit breaker open - too many recent failures",
503,
);
throw new ModrinthServerError("Service temporarily unavailable", 503, error, module);
}
if (now - lastFailureTime.value > 30000) {
failureCount.value = 0;
}
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace( const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
/\/$/, /\/$/,
"", "",
@@ -86,7 +69,6 @@ export async function useServersFetch<T>(
const headers: Record<string, string> = { const headers: Record<string, string> = {
"User-Agent": "Modrinth/1.0 (https://modrinth.com)", "User-Agent": "Modrinth/1.0 (https://modrinth.com)",
"X-Archon-Request": "true",
Vary: "Accept, Origin", Vary: "Accept, Origin",
}; };
@@ -112,12 +94,10 @@ export async function useServersFetch<T>(
const response = await $fetch<T>(fullUrl, { const response = await $fetch<T>(fullUrl, {
method, method,
headers, headers,
body: body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
body && contentType === "application/json" ? JSON.stringify(body) : (body ?? undefined),
timeout: 10000, timeout: 10000,
}); });
failureCount.value = 0;
return response; return response;
} catch (error) { } catch (error) {
lastError = error as Error; lastError = error as Error;
@@ -127,11 +107,6 @@ export async function useServersFetch<T>(
const statusCode = error.response?.status; const statusCode = error.response?.status;
const statusText = error.response?.statusText || "Unknown error"; const statusText = error.response?.statusText || "Unknown error";
if (statusCode && statusCode >= 500) {
failureCount.value++;
lastFailureTime.value = now;
}
let v1Error: V1ErrorInfo | undefined; let v1Error: V1ErrorInfo | undefined;
if (error.data?.error && error.data?.description) { if (error.data?.error && error.data?.description) {
v1Error = { v1Error = {
@@ -159,11 +134,9 @@ export async function useServersFetch<T>(
? errorMessages[statusCode] ? errorMessages[statusCode]
: `HTTP Error: ${statusCode || "unknown"} ${statusText}`; : `HTTP Error: ${statusCode || "unknown"} ${statusText}`;
const isRetryable = statusCode ? [408, 429].includes(statusCode) : false; const isRetryable = statusCode ? [408, 429, 500, 502, 504].includes(statusCode) : true;
const is5xxRetryable =
statusCode && statusCode >= 500 && statusCode < 600 && method === "GET" && attempts === 1;
if (!(isRetryable || is5xxRetryable) || attempts >= maxAttempts) { if (!isRetryable || attempts >= maxAttempts) {
console.error("Fetch error:", error); console.error("Fetch error:", error);
const fetchError = new ModrinthServersFetchError( const fetchError = new ModrinthServersFetchError(
@@ -174,8 +147,7 @@ export async function useServersFetch<T>(
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error); throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error);
} }
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000; const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000);
const delay = Math.min(baseDelay * Math.pow(2, attempts - 1) + Math.random() * 1000, 15000);
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`); console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`);
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
continue; continue;

View File

@@ -1,12 +0,0 @@
export const useNotificationRightwards = () => {
const isVisible = useState("moderation-checklist-notifications", () => false);
const setVisible = (visible: boolean) => {
isVisible.value = visible;
};
return {
isVisible: readonly(isVisible),
setVisible,
};
};

View File

@@ -142,7 +142,7 @@
" "
> >
<nuxt-link to="/resourcepacks"> <nuxt-link to="/resourcepacks">
<PaintbrushIcon aria-hidden="true" /> Resource Packs <PaintBrushIcon aria-hidden="true" /> Resource Packs
</nuxt-link> </nuxt-link>
</ButtonStyled> </ButtonStyled>
<ButtonStyled <ButtonStyled
@@ -221,7 +221,7 @@
v-if="route.name === 'search-mods' || route.path.startsWith('/mod/')" v-if="route.name === 'search-mods' || route.path.startsWith('/mod/')"
aria-hidden="true" aria-hidden="true"
/> />
<PaintbrushIcon <PaintBrushIcon
v-else-if=" v-else-if="
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/') route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
" "
@@ -250,7 +250,7 @@
<template #mods> <BoxIcon aria-hidden="true" /> Mods </template> <template #mods> <BoxIcon aria-hidden="true" /> Mods </template>
<template #resourcepacks> <template #resourcepacks>
<PaintbrushIcon aria-hidden="true" /> Resource Packs <PaintBrushIcon aria-hidden="true" /> Resource Packs
</template> </template>
<template #datapacks> <BracesIcon aria-hidden="true" /> Data Packs </template> <template #datapacks> <BracesIcon aria-hidden="true" /> Data Packs </template>
<template #plugins> <PlugIcon aria-hidden="true" /> Plugins </template> <template #plugins> <PlugIcon aria-hidden="true" /> Plugins </template>
@@ -696,13 +696,14 @@ import {
CurrencyIcon, CurrencyIcon,
BracesIcon, BracesIcon,
GlassesIcon, GlassesIcon,
PaintbrushIcon, PaintBrushIcon,
PackageOpenIcon, PackageOpenIcon,
DiscordIcon, DiscordIcon,
BlueskyIcon, BlueskyIcon,
TumblrIcon,
TwitterIcon, TwitterIcon,
MastodonIcon, MastodonIcon,
GithubIcon, GitHubIcon,
ScaleIcon, ScaleIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { import {
@@ -1184,6 +1185,13 @@ const socialLinks = [
icon: MastodonIcon, icon: MastodonIcon,
rel: "me", rel: "me",
}, },
{
label: formatMessage(
defineMessage({ id: "layout.footer.social.tumblr", defaultMessage: "Tumblr" }),
),
href: "https://tumblr.com/modrinth",
icon: TumblrIcon,
},
{ {
label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })), label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
href: "https://x.com/modrinth", href: "https://x.com/modrinth",
@@ -1194,7 +1202,7 @@ const socialLinks = [
defineMessage({ id: "layout.footer.social.github", defaultMessage: "GitHub" }), defineMessage({ id: "layout.footer.social.github", defaultMessage: "GitHub" }),
), ),
href: "https://github.com/modrinth", href: "https://github.com/modrinth",
icon: GithubIcon, icon: GitHubIcon,
}, },
]; ];
@@ -1203,9 +1211,9 @@ const footerLinks = [
label: formatMessage(defineMessage({ id: "layout.footer.about", defaultMessage: "About" })), label: formatMessage(defineMessage({ id: "layout.footer.about", defaultMessage: "About" })),
links: [ links: [
{ {
href: "/news", href: "https://blog.modrinth.com",
label: formatMessage( label: formatMessage(
defineMessage({ id: "layout.footer.about.news", defaultMessage: "News" }), defineMessage({ id: "layout.footer.about.blog", defaultMessage: "Blog" }),
), ),
}, },
{ {
@@ -1338,15 +1346,6 @@ const footerLinks = [
}), }),
), ),
}, },
{
href: "/legal/copyright",
label: formatMessage(
defineMessage({
id: "layout.footer.legal.copyright-policy",
defaultMessage: "Copyright Policy and DMCA",
}),
),
},
], ],
}, },
]; ];

View File

@@ -383,15 +383,15 @@
"layout.footer.about": { "layout.footer.about": {
"message": "About" "message": "About"
}, },
"layout.footer.about.blog": {
"message": "Blog"
},
"layout.footer.about.careers": { "layout.footer.about.careers": {
"message": "Careers" "message": "Careers"
}, },
"layout.footer.about.changelog": { "layout.footer.about.changelog": {
"message": "Changelog" "message": "Changelog"
}, },
"layout.footer.about.news": {
"message": "News"
},
"layout.footer.about.rewards-program": { "layout.footer.about.rewards-program": {
"message": "Rewards Program" "message": "Rewards Program"
}, },
@@ -404,9 +404,6 @@
"layout.footer.legal-disclaimer": { "layout.footer.legal-disclaimer": {
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT." "message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
}, },
"layout.footer.legal.copyright-policy": {
"message": "Copyright Policy and DMCA"
},
"layout.footer.legal.privacy-policy": { "layout.footer.legal.privacy-policy": {
"message": "Privacy Policy" "message": "Privacy Policy"
}, },
@@ -461,6 +458,9 @@
"layout.footer.social.mastodon": { "layout.footer.social.mastodon": {
"message": "Mastodon" "message": "Mastodon"
}, },
"layout.footer.social.tumblr": {
"message": "Tumblr"
},
"layout.footer.social.x": { "layout.footer.social.x": {
"message": "X" "message": "X"
}, },

View File

@@ -29,11 +29,12 @@
class="settings-header__icon" class="settings-header__icon"
/> />
<div class="settings-header__text"> <div class="settings-header__text">
<h1 class="wrap-as-needed">{{ project.title }}</h1> <h1 class="wrap-as-needed">
{{ project.title }}
</h1>
<ProjectStatusBadge :status="project.status" /> <ProjectStatusBadge :status="project.status" />
</div> </div>
</div> </div>
<h2>Project settings</h2> <h2>Project settings</h2>
<NavStack> <NavStack>
<NavStackItem <NavStackItem
@@ -110,7 +111,6 @@
</NavStack> </NavStack>
</aside> </aside>
</div> </div>
<div class="normal-page__content"> <div class="normal-page__content">
<ProjectMemberHeader <ProjectMemberHeader
v-if="currentMember" v-if="currentMember"
@@ -145,7 +145,6 @@
/> />
</div> </div>
</div> </div>
<div v-else class="experimental-styles-within"> <div v-else class="experimental-styles-within">
<NewModal ref="settingsModal"> <NewModal ref="settingsModal">
<template #title> <template #title>
@@ -175,11 +174,9 @@
<div <div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40" class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div> ></div>
<div <div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60" class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div> ></div>
<div <div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight" class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
> >
@@ -222,7 +219,8 @@
:href="`modrinth://mod/${project.slug}`" :href="`modrinth://mod/${project.slug}`"
@click="() => installWithApp()" @click="() => installWithApp()"
> >
<ModrinthIcon aria-hidden="true" /> Install with Modrinth App <ModrinthIcon aria-hidden="true" />
Install with Modrinth App
<ExternalIcon aria-hidden="true" /> <ExternalIcon aria-hidden="true" />
</a> </a>
</ButtonStyled> </ButtonStyled>
@@ -242,7 +240,6 @@
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div> <div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
</div> </div>
</div> </div>
<div class="mx-auto flex w-fit flex-col gap-2"> <div class="mx-auto flex w-fit flex-col gap-2">
<ButtonStyled v-if="project.game_versions.length === 1"> <ButtonStyled v-if="project.game_versions.length === 1">
<div class="disabled button-like"> <div class="disabled button-like">
@@ -330,7 +327,8 @@
} }
" "
> >
{{ gameVersion }} <CheckIcon v-if="userSelectedGameVersion === gameVersion" /> {{ gameVersion }}
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
</button> </button>
</ButtonStyled> </ButtonStyled>
</ScrollablePanel> </ScrollablePanel>
@@ -421,6 +419,7 @@
</ScrollablePanel> </ScrollablePanel>
</Accordion> </Accordion>
</div> </div>
<AutomaticAccordion div class="flex flex-col gap-2"> <AutomaticAccordion div class="flex flex-col gap-2">
<VersionSummary <VersionSummary
v-if="filteredRelease" v-if="filteredRelease"
@@ -471,14 +470,10 @@
class="new-page sidebar" class="new-page sidebar"
:class="{ :class="{
'alt-layout': cosmetics.leftContentLayout, 'alt-layout': cosmetics.leftContentLayout,
'checklist-open': 'ultimate-sidebar':
showModerationChecklist && showModerationChecklist &&
!collapsedModerationChecklist && !collapsedModerationChecklist &&
!flags.alwaysShowChecklistAsPopup, !flags.alwaysShowChecklistAsPopup,
'checklist-collapsed':
showModerationChecklist &&
collapsedModerationChecklist &&
!flags.alwaysShowChecklistAsPopup,
}" }"
> >
<div class="normal-page__header relative my-4"> <div class="normal-page__header relative my-4">
@@ -490,11 +485,11 @@
:color="route.name === 'type-id-version-version' ? `standard` : `brand`" :color="route.name === 'type-id-version-version' ? `standard` : `brand`"
> >
<button @click="(event) => downloadModal.show(event)"> <button @click="(event) => downloadModal.show(event)">
<DownloadIcon aria-hidden="true" /> Download <DownloadIcon aria-hidden="true" />
Download
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<div class="contents sm:hidden"> <div class="contents sm:hidden">
<ButtonStyled <ButtonStyled
size="large" size="large"
@@ -559,11 +554,9 @@
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary"> <p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
Modrinth Servers is the easiest way to play with your friends without hassle! Modrinth Servers is the easiest way to play with your friends without hassle!
</p> </p>
<p class="m-0 text-wrap text-sm font-bold text-primary"> <p class="m-0 text-wrap text-sm font-bold text-primary">
Starting at $5<span class="text-xs"> / month</span> Starting at $5<span class="text-xs"> / month</span>
</p> </p>
@@ -628,7 +621,6 @@
{{ option.name }} {{ option.name }}
</Checkbox> </Checkbox>
</div> </div>
<div v-else class="menu-text"> <div v-else class="menu-text">
<p class="popout-text">No collections found.</p> <p class="popout-text">No collections found.</p>
</div> </div>
@@ -636,7 +628,8 @@
class="btn collection-button" class="btn collection-button"
@click="(event) => $refs.modal_collection.show(event)" @click="(event) => $refs.modal_collection.show(event)"
> >
<PlusIcon aria-hidden="true" /> Create new collection <PlusIcon aria-hidden="true" />
Create new collection
</button> </button>
</template> </template>
</PopoutMenu> </PopoutMenu>
@@ -719,14 +712,25 @@
:dropdown-id="`${baseId}-more-options`" :dropdown-id="`${baseId}-more-options`"
> >
<MoreVerticalIcon aria-hidden="true" /> <MoreVerticalIcon aria-hidden="true" />
<template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template> <template #analytics>
<template #moderation-checklist> <ChartIcon aria-hidden="true" />
<ScaleIcon aria-hidden="true" /> Review project Analytics
</template>
<template #moderation-checklist>
<ScaleIcon aria-hidden="true" />
Review project
</template>
<template #report>
<ReportIcon aria-hidden="true" />
Report
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
</template> </template>
<template #report> <ReportIcon aria-hidden="true" /> Report </template>
<template #copy-id> <ClipboardCopyIcon aria-hidden="true" /> Copy ID </template>
<template #copy-permalink> <template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" /> Copy permanent link <ClipboardCopyIcon aria-hidden="true" />
Copy permanent link
</template> </template>
</OverflowMenu> </OverflowMenu>
</ButtonStyled> </ButtonStyled>
@@ -752,14 +756,18 @@
updates unless the author decides to unarchive the project. updates unless the author decides to unarchive the project.
</MessageBanner> </MessageBanner>
</div> </div>
<div class="normal-page__sidebar"> <div class="normal-page__sidebar">
<ProjectSidebarCompatibility <ProjectSidebarCompatibility
:project="project" :project="project"
:tags="tags" :tags="tags"
class="card flex-card experimental-styles-within" class="card flex-card experimental-styles-within"
/> />
<AdPlaceholder v-if="!auth.user && tags.approvedStatuses.includes(project.status)" /> <AdPlaceholder
v-if="
(!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus) &&
tags.approvedStatuses.includes(project.status)
"
/>
<ProjectSidebarLinks <ProjectSidebarLinks
:project="project" :project="project"
:link-target="$external()" :link-target="$external()"
@@ -782,7 +790,6 @@
/> />
<div class="card flex-card experimental-styles-within"> <div class="card flex-card experimental-styles-within">
<h2>{{ formatMessage(detailsMessages.title) }}</h2> <h2>{{ formatMessage(detailsMessages.title) }}</h2>
<div class="details-list"> <div class="details-list">
<div class="details-list__item"> <div class="details-list__item">
<BookTextIcon aria-hidden="true" /> <BookTextIcon aria-hidden="true" />
@@ -811,48 +818,53 @@
<span v-else>{{ licenseIdDisplay }}</span> <span v-else>{{ licenseIdDisplay }}</span>
</div> </div>
</div> </div>
<div <div
v-if="project.approved" v-if="project.approved"
v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')" v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item" class="details-list__item"
> >
<CalendarIcon aria-hidden="true" /> <CalendarIcon aria-hidden="true" />
<div>{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}</div> <div>
{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}
</div>
</div> </div>
<div <div
v-else v-else
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')" v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item" class="details-list__item"
> >
<CalendarIcon aria-hidden="true" /> <CalendarIcon aria-hidden="true" />
<div>{{ formatMessage(detailsMessages.created, { date: createdDate }) }}</div> <div>
{{ formatMessage(detailsMessages.created, { date: createdDate }) }}
</div>
</div> </div>
<div <div
v-if="project.status === 'processing' && project.queued" v-if="project.status === 'processing' && project.queued"
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')" v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item" class="details-list__item"
> >
<ScaleIcon aria-hidden="true" /> <ScaleIcon aria-hidden="true" />
<div>{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}</div> <div>
{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}
</div>
</div> </div>
<div <div
v-if="versions.length > 0 && project.updated" v-if="versions.length > 0 && project.updated"
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')" v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item" class="details-list__item"
> >
<VersionIcon aria-hidden="true" /> <VersionIcon aria-hidden="true" />
<div>{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}</div> <div>
{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="normal-page__content"> <div class="normal-page__content">
<div class="overflow-x-auto"><NavTabs :links="navLinks" class="mb-4" /></div> <div class="overflow-x-auto">
<NavTabs :links="navLinks" class="mb-4" />
</div>
<NuxtPage <NuxtPage
v-model:project="project" v-model:project="project"
v-model:versions="versions" v-model:versions="versions"
@@ -870,10 +882,8 @@
@delete-version="deleteVersion" @delete-version="deleteVersion"
/> />
</div> </div>
<div class="normal-page__ultimate-sidebar"> <div class="normal-page__ultimate-sidebar">
<!-- Uncomment this to enable the old moderation checklist. --> <ModerationChecklist
<!-- <ModerationChecklist
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist" v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
:project="project" :project="project"
:future-projects="futureProjects" :future-projects="futureProjects"
@@ -881,25 +891,11 @@
:collapsed="collapsedModerationChecklist" :collapsed="collapsedModerationChecklist"
@exit="showModerationChecklist = false" @exit="showModerationChecklist = false"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist" @toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/> --> />
</div> </div>
</div> </div>
</div> </div>
<div
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
class="moderation-checklist"
>
<NewModerationChecklist
:project="project"
:future-project-ids="futureProjectIds"
:collapsed="collapsedModerationChecklist"
@exit="showModerationChecklist = false"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/>
</div>
</template> </template>
<script setup> <script setup>
import { import {
BookmarkIcon, BookmarkIcon,
@@ -959,16 +955,16 @@ import {
isUnderReview, isUnderReview,
renderString, renderString,
} from "@modrinth/utils"; } from "@modrinth/utils";
import { navigateTo } from "#app";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Tooltip } from "floating-vue"; import { Tooltip } from "floating-vue";
import { useLocalStorage } from "@vueuse/core";
import { navigateTo } from "#app";
import Accordion from "~/components/ui/Accordion.vue"; import Accordion from "~/components/ui/Accordion.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue"; import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue"; import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue"; import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue"; import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
import MessageBanner from "~/components/ui/MessageBanner.vue"; import MessageBanner from "~/components/ui/MessageBanner.vue";
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
import NavStack from "~/components/ui/NavStack.vue"; import NavStack from "~/components/ui/NavStack.vue";
import NavStackItem from "~/components/ui/NavStackItem.vue"; import NavStackItem from "~/components/ui/NavStackItem.vue";
import NavTabs from "~/components/ui/NavTabs.vue"; import NavTabs from "~/components/ui/NavTabs.vue";
@@ -976,7 +972,6 @@ import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
import { userCollectProject } from "~/composables/user.js"; import { userCollectProject } from "~/composables/user.js";
import { reportProject } from "~/utils/report-helpers.ts"; import { reportProject } from "~/utils/report-helpers.ts";
import { saveFeatureFlags } from "~/composables/featureFlags.ts"; import { saveFeatureFlags } from "~/composables/featureFlags.ts";
import NewModerationChecklist from "~/components/ui/moderation/NewModerationChecklist.vue";
const data = useNuxtApp(); const data = useNuxtApp();
const route = useNativeRoute(); const route = useNativeRoute();
@@ -990,7 +985,6 @@ const flags = useFeatureFlags();
const cosmetics = useCosmetics(); const cosmetics = useCosmetics();
const { formatMessage } = useVIntl(); const { formatMessage } = useVIntl();
const { setVisible } = useNotificationRightwards();
const settingsModal = ref(); const settingsModal = ref();
const downloadModal = ref(); const downloadModal = ref();
@@ -1562,28 +1556,12 @@ async function copyPermalink() {
const collapsedChecklist = ref(false); const collapsedChecklist = ref(false);
const showModerationChecklist = useLocalStorage( const showModerationChecklist = ref(false);
`show-moderation-checklist-${project.value.id}`, const collapsedModerationChecklist = ref(false);
false, const futureProjects = ref([]);
);
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
const futureProjectIds = useLocalStorage("moderation-future-projects", []);
watch(futureProjectIds, (newValue) => {
console.log("Future project IDs updated:", newValue);
});
watch(
showModerationChecklist,
(newValue) => {
setVisible(newValue);
},
{ immediate: true },
);
if (import.meta.client && history && history.state && history.state.showChecklist) { if (import.meta.client && history && history.state && history.state.showChecklist) {
showModerationChecklist.value = true; showModerationChecklist.value = true;
futureProjects.value = history.state.projects;
} }
function closeDownloadModal(event) { function closeDownloadModal(event) {
@@ -1653,7 +1631,6 @@ const navLinks = computed(() => {
]; ];
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.settings-header { .settings-header {
display: flex; display: flex;
@@ -1809,16 +1786,4 @@ const navLinks = computed(() => {
left: 18px; left: 18px;
} }
} }
.moderation-checklist {
position: fixed;
bottom: 1rem;
right: 1rem;
overflow-y: auto;
z-index: 50;
> div {
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
}
</style> </style>

View File

@@ -705,9 +705,9 @@ export default defineNuxtComponent({
} }
.gallery-body { .gallery-body {
flex-grow: 1;
width: calc(100% - 2 * var(--spacing-card-md)); width: calc(100% - 2 * var(--spacing-card-md));
padding: var(--spacing-card-sm) var(--spacing-card-md); padding: var(--spacing-card-sm) var(--spacing-card-md);
overflow-wrap: anywhere;
.gallery-info { .gallery-info {
h2 { h2 {

View File

@@ -50,7 +50,7 @@
Listed in search results Listed in search results
</li> </li>
<li v-else> <li v-else>
<XIcon class="bad" /> <ExitIcon class="bad" />
Not listed in search results Not listed in search results
</li> </li>
<li v-if="isListed(project)"> <li v-if="isListed(project)">
@@ -58,11 +58,11 @@
Listed on the profiles of members Listed on the profiles of members
</li> </li>
<li v-else> <li v-else>
<XIcon class="bad" /> <ExitIcon class="bad" />
Not listed on the profiles of members Not listed on the profiles of members
</li> </li>
<li v-if="isPrivate(project)"> <li v-if="isPrivate(project)">
<XIcon class="bad" /> <ExitIcon class="bad" />
Not accessible with a direct link Not accessible with a direct link
</li> </li>
<li v-else> <li v-else>
@@ -92,7 +92,7 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { XIcon, CheckIcon, IssuesIcon } from "@modrinth/assets"; import { ExitIcon, CheckIcon, IssuesIcon } from "@modrinth/assets";
import { Badge } from "@modrinth/ui"; import { Badge } from "@modrinth/ui";
import ConversationThread from "~/components/ui/thread/ConversationThread.vue"; import ConversationThread from "~/components/ui/thread/ConversationThread.vue";
import { import {

View File

@@ -7,14 +7,14 @@
{{ formatProjectType(project.project_type).toLowerCase() }}. You may choose one from our {{ formatProjectType(project.project_type).toLowerCase() }}. You may choose one from our
list or provide a custom license. You may also provide a custom URL to your chosen license; list or provide a custom license. You may also provide a custom URL to your chosen license;
otherwise, the license text will be displayed. See our otherwise, the license text will be displayed. See our
<nuxt-link <a
to="/news/article/licensing-guide/" href="https://blog.modrinth.com/licensing-guide/"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
class="text-link" class="text-link"
> >
licensing guide licensing guide
</nuxt-link> </a>
for more information. for more information.
</p> </p>

View File

@@ -153,7 +153,10 @@
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }} {{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span> <span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
</span> </span>
<div class="flex w-full items-center gap-1 text-xs text-secondary"> <div
v-if="flags.developerMode"
class="flex w-full items-center gap-1 text-xs text-secondary"
>
{{ charge.status }} {{ charge.status }}
{{ charge.type }} {{ charge.type }}
@@ -216,6 +219,7 @@ import dayjs from "dayjs";
import { products } from "~/generated/state.json"; import { products } from "~/generated/state.json";
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue"; import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
const flags = useFeatureFlags();
const route = useRoute(); const route = useRoute();
const data = useNuxtApp(); const data = useNuxtApp();
const vintl = useVIntl(); const vintl = useVIntl();
@@ -285,13 +289,13 @@ const selectedCharge = ref(null);
const refundType = ref("full"); const refundType = ref("full");
const refundTypes = ref(["full", "partial", "none"]); const refundTypes = ref(["full", "partial", "none"]);
const refundAmount = ref(0); const refundAmount = ref(0);
const unprovision = ref(true); const unprovision = ref(false);
function showRefundModal(charge) { function showRefundModal(charge) {
selectedCharge.value = charge; selectedCharge.value = charge;
refundType.value = "full"; refundType.value = "full";
refundAmount.value = 0; refundAmount.value = 0;
unprovision.value = true; unprovision.value = false;
refundModal.value.show(); refundModal.value.show();
} }

File diff suppressed because one or more lines are too long

View File

@@ -248,7 +248,9 @@
</div> </div>
</template> </template>
</div> </div>
<AdPlaceholder v-if="!auth.user" /> <AdPlaceholder
v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus"
/>
</div> </div>
<div class="normal-page__content"> <div class="normal-page__content">
<nav class="navigation-card"> <nav class="navigation-card">
@@ -490,6 +492,7 @@ const route = useNativeRoute();
const auth = await useAuth(); const auth = await useAuth();
const cosmetics = useCosmetics(); const cosmetics = useCosmetics();
const tags = useTags(); const tags = useTags();
const flags = useFeatureFlags();
const isEditing = ref(false); const isEditing = ref(false);

View File

@@ -146,7 +146,7 @@
/> />
<div class="push-right input-group"> <div class="push-right input-group">
<button class="iconified-button" @click="$refs.editLinksModal.hide()"> <button class="iconified-button" @click="$refs.editLinksModal.hide()">
<XIcon /> <CrossIcon />
Cancel Cancel
</button> </button>
<button class="iconified-button brand-button" @click="bulkEditLinks()"> <button class="iconified-button brand-button" @click="bulkEditLinks()">
@@ -199,8 +199,8 @@
class="square-button" class="square-button"
@click="updateDescending()" @click="updateDescending()"
> >
<SortDescIcon v-if="descending" /> <DescendingIcon v-if="descending" />
<SortAscIcon v-else /> <AscendingIcon v-else />
</button> </button>
</div> </div>
</div> </div>
@@ -306,12 +306,12 @@ import {
SettingsIcon, SettingsIcon,
TrashIcon, TrashIcon,
PlusIcon, PlusIcon,
XIcon, XIcon as CrossIcon,
IssuesIcon, IssuesIcon,
EditIcon, EditIcon,
SaveIcon, SaveIcon,
SortAscIcon, SortAscendingIcon as AscendingIcon,
SortDescIcon, SortDescendingIcon as DescendingIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { import {
Avatar, Avatar,
@@ -337,15 +337,15 @@ export default defineNuxtComponent({
Checkbox, Checkbox,
IssuesIcon, IssuesIcon,
PlusIcon, PlusIcon,
XIcon, CrossIcon,
EditIcon, EditIcon,
SaveIcon, SaveIcon,
Modal, Modal,
ModalCreation, ModalCreation,
Multiselect, Multiselect,
CopyCode, CopyCode,
SortAscIcon, AscendingIcon,
SortDescIcon, DescendingIcon,
}, },
async setup() { async setup() {
const { formatMessage } = useVIntl(); const { formatMessage } = useVIntl();

View File

@@ -266,12 +266,12 @@ const getRangeOfMethod = (method) => {
const maxWithdrawAmount = computed(() => { const maxWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval; const interval = selectedMethod.value.interval;
return interval?.standard ? interval.standard.max : (interval?.fixed?.values.slice(-1)[0] ?? 0); return interval?.standard ? interval.standard.max : interval?.fixed?.values.slice(-1)[0] ?? 0;
}); });
const minWithdrawAmount = computed(() => { const minWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval; const interval = selectedMethod.value.interval;
return interval?.standard ? interval.standard.min : (interval?.fixed?.values?.[0] ?? fees.value); return interval?.standard ? interval.standard.min : interval?.fixed?.values?.[0] ?? fees.value;
}); });
const withdrawAccount = computed(() => { const withdrawAccount = computed(() => {

File diff suppressed because one or more lines are too long

View File

@@ -147,9 +147,9 @@
<tbody> <tbody>
<tr v-for="item in platformRevenueData" :key="item.time"> <tr v-for="item in platformRevenueData" :key="item.time">
<td>{{ formatDate(dayjs.unix(item.time)) }}</td> <td>{{ formatDate(dayjs.unix(item.time)) }}</td>
<td>{{ formatMoney(Number(item.revenue) + Number(item.creator_revenue)) }}</td> <td>{{ formatMoney(item.revenue) }}</td>
<td>{{ formatMoney(Number(item.creator_revenue)) }}</td> <td>{{ formatMoney(item.creator_revenue) }}</td>
<td>{{ formatMoney(Number(item.revenue)) }}</td> <td>{{ formatMoney(item.revenue - item.creator_revenue) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -187,6 +187,6 @@ const { data: transparencyInformation } = await useAsyncData("payout/platform_re
}), }),
); );
const platformRevenue = (transparencyInformation.value as any)?.all_time; const platformRevenue = transparencyInformation.value.all_time;
const platformRevenueData = (transparencyInformation.value as any)?.data?.slice(0, 5) ?? []; const platformRevenueData = transparencyInformation.value.data.slice(0, 5);
</script> </script>

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