Compare commits
1 Commits
alex/db-se
...
cal/dev-76
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf309fd641 |
@@ -1,9 +1,6 @@
|
||||
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
|
||||
[target.'cfg(windows)']
|
||||
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
|
||||
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "link-args=/STACK:16777220"]
|
||||
|
||||
[build]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
.gitignore
|
||||
@@ -14,5 +14,5 @@ max_line_length = 100
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{rs,java,kts}]
|
||||
indent_size = 4
|
||||
[*.rs]
|
||||
indent_size = 4
|
||||
34
.gitattributes
vendored
34
.gitattributes
vendored
@@ -1,35 +1 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
# SQLx calculates a checksum of migration scripts at build time to compare
|
||||
# it with the checksum of the applied migration for the same version at
|
||||
# runtime, to know if the migration script has been changed, and thus the
|
||||
# DB schema went out of sync with the code.
|
||||
#
|
||||
# However, such checksum treats the script as a raw byte stream, taking
|
||||
# into account inconsequential differences like different line endings
|
||||
# in different OSes. When combined with Git's EOL conversion and mixed
|
||||
# native and cross-compilation scenarios, this leads to existing
|
||||
# migrations that didn't change having potentially different checksums
|
||||
# according to the environment they were built in, which can break the
|
||||
# migration system when deploying the Modrinth App, rendering it
|
||||
# unusable.
|
||||
#
|
||||
# The gitattribute above ensures that all text files are checked out
|
||||
# with LF line endings, but widely deployed app versions were built
|
||||
# without this attribute set, which left such line endings variable to
|
||||
# the platform. Thus, there is no perfect solution to this problem:
|
||||
# forcing CRLF here would break Linux and macOS users, forcing LF
|
||||
# breaks Windows users, and leaving it unspecified may still lead to
|
||||
# line ending differences when cross-compiling from Linux to Windows
|
||||
# or vice versa, or having Git configured with different line
|
||||
# conversion settings. Moreover, there is no `eol=native` attribute,
|
||||
# and using CI-only scripts to convert line endings would make the
|
||||
# builds differ between CI and most local environments. So, let's pick
|
||||
# the least bad option: let Git handle line endings using its
|
||||
# configuration by leaving it unspecified, which works fine as long as
|
||||
# people don't mess with Git's line ending settings, which is the vast
|
||||
# majority of cases.
|
||||
/packages/app-lib/migrations/20240711194701_init.sql !eol
|
||||
/packages/app-lib/migrations/20240813205023_drop-active-unique.sql !eol
|
||||
/packages/app-lib/migrations/20240930001852_disable-personalized-ads.sql !eol
|
||||
/packages/app-lib/migrations/20241222013857_feature-flags.sql !eol
|
||||
|
||||
25
.github/CODEOWNERS
vendored
Normal file
25
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
/apps/frontend/ @modrinth/frontend
|
||||
/apps/app-frontend/ @modrinth/frontend
|
||||
/apps/daedalus_client/ @modrinth/backend
|
||||
/apps/docs/ @modrinth/support @modrinth/platform @modrinth/servers
|
||||
/apps/labrinth @modrinth/backend
|
||||
/apps/app @modrinth/backend
|
||||
|
||||
/packages/app-lib/ @modrinth/backend
|
||||
/packages/ariadne/ @modrinth/backend
|
||||
/packages/assets/ @modrinth/frontend
|
||||
/packages/daedalus/ @modrinth/backend
|
||||
/packages/eslint-config-custom/ @modrinth/frontend
|
||||
/packages/tsconfig/ @modrinth/frontend
|
||||
/packages/ui/ @modrinth/frontend
|
||||
/packages/utils/ @modrinth/frontend
|
||||
|
||||
README.md @modrinth/support
|
||||
|
||||
LICENSE @Geometrically @Prospector
|
||||
/COPYING.md @Geometrically @Prospector
|
||||
/apps/frontend/src/pages/legal/ @Geometrically @Prospector
|
||||
|
||||
/.github/ @Geometrically @Prospector
|
||||
|
||||
/docker-compose.yml @modrinth/backend
|
||||
13
.github/workflows/daedalus-docker.yml
vendored
13
.github/workflows/daedalus-docker.yml
vendored
@@ -22,26 +22,23 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: actions/checkout@v2
|
||||
- name: Fetch docker metadata
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: ghcr.io/modrinth/daedalus
|
||||
- name: Login to GitHub Images
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
file: ./apps/daedalus_client/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=ghcr.io/modrinth/daedalus:main
|
||||
cache-to: type=inline
|
||||
|
||||
18
.github/workflows/labrinth-docker.yml
vendored
18
.github/workflows/labrinth-docker.yml
vendored
@@ -18,28 +18,30 @@ on:
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./apps/labrinth
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: actions/checkout@v2
|
||||
- name: Fetch docker metadata
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: ghcr.io/modrinth/labrinth
|
||||
- name: Login to GitHub Images
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
env:
|
||||
SQLX_OFFLINE: true
|
||||
with:
|
||||
file: ./apps/labrinth/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=ghcr.io/modrinth/labrinth:main
|
||||
cache-to: type=inline
|
||||
|
||||
152
.github/workflows/theseus-build.yml
vendored
152
.github/workflows/theseus-build.yml
vendored
@@ -1,152 +0,0 @@
|
||||
name: Modrinth App build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
paths:
|
||||
- .github/workflows/theseus-build.yml
|
||||
- 'apps/app/**'
|
||||
- 'apps/app-frontend/**'
|
||||
- 'packages/app-lib/**'
|
||||
- 'packages/app-macros/**'
|
||||
- 'packages/assets/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/utils/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sign-windows-binaries:
|
||||
description: Sign Windows binaries
|
||||
type: boolean
|
||||
default: true
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [macos-latest, windows-latest, ubuntu-22.04]
|
||||
include:
|
||||
- platform: macos-latest
|
||||
artifact-target-name: universal-apple-darwin
|
||||
- platform: windows-latest
|
||||
artifact-target-name: x86_64-pc-windows-msvc
|
||||
- platform: ubuntu-22.04
|
||||
artifact-target-name: x86_64-unknown-linux-gnu
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: 📥 Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🧰 Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
rustflags: ''
|
||||
target: ${{ startsWith(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: 🧰 Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: 🧰 Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
|
||||
- name: 🧰 Install Linux build dependencies
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
|
||||
- name: 🧰 Setup Dasel
|
||||
uses: jaxxstorm/action-install-gh-release@v2.1.0
|
||||
with:
|
||||
repo: TomWright/dasel
|
||||
tag: v2.8.1
|
||||
extension-matching: disable
|
||||
rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
|
||||
chmod: 0755
|
||||
|
||||
- name: ⚙️ Set application version and environment
|
||||
shell: bash
|
||||
run: |
|
||||
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
|
||||
echo "Setting application version to $APP_VERSION"
|
||||
dasel put -f apps/app/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
|
||||
dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
|
||||
dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version'
|
||||
|
||||
cp packages/app-lib/.env.prod packages/app-lib/.env
|
||||
|
||||
- name: 💨 Setup Turbo cache
|
||||
uses: rharkor/caching-for-turbo@v1.8
|
||||
|
||||
- name: 🧰 Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: ✍️ Set up Windows code signing
|
||||
if: startsWith(matrix.platform, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
if [ '${{ startsWith(github.ref, 'refs/tags/v') || inputs.sign-windows-binaries }}' = 'true' ]; then
|
||||
choco install jsign --ignore-dependencies # GitHub runners come with a global Java installation already
|
||||
else
|
||||
dasel delete -f apps/app/tauri-release.conf.json 'bundle.windows.signCommand'
|
||||
fi
|
||||
|
||||
- name: 🔨 Build macOS app
|
||||
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
env:
|
||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: 🔨 Build Linux app
|
||||
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: 🔨 Build Windows app
|
||||
run: |
|
||||
[System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream
|
||||
$env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD"
|
||||
$env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
|
||||
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater'
|
||||
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
|
||||
if: startsWith(matrix.platform, 'windows')
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
DIGICERT_ONE_SIGNER_API_KEY: ${{ secrets.DIGICERT_ONE_SIGNER_API_KEY }}
|
||||
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64 }}
|
||||
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: 📤 Upload app bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: App bundle (${{ matrix.artifact-target-name }})
|
||||
path: |
|
||||
target/release/bundle/appimage/Modrinth App_*.AppImage*
|
||||
target/release/bundle/deb/Modrinth App_*.deb*
|
||||
target/release/bundle/rpm/Modrinth App-*.rpm*
|
||||
target/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz*
|
||||
target/universal-apple-darwin/release/bundle/dmg/Modrinth App_*.dmg*
|
||||
target/release/bundle/nsis/Modrinth App_*-setup.exe*
|
||||
target/release/bundle/nsis/Modrinth App_*-setup.nsis.zip*
|
||||
245
.github/workflows/theseus-release.yml
vendored
245
.github/workflows/theseus-release.yml
vendored
@@ -1,118 +1,157 @@
|
||||
name: Modrinth App release
|
||||
name: 'Modrinth App build'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
paths:
|
||||
- .github/workflows/theseus-release.yml
|
||||
- 'apps/app/**'
|
||||
- 'apps/app-frontend/**'
|
||||
- 'apps/labrinth/src/common/**'
|
||||
- 'apps/labrinth/Cargo.toml'
|
||||
- 'packages/app-lib/**'
|
||||
- 'packages/app-macros/**'
|
||||
- 'packages/assets/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/utils/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version-tag:
|
||||
description: Version tag to release to the wide public
|
||||
type: string
|
||||
required: true
|
||||
release-notes:
|
||||
description: Release notes to include in the Tauri version manifest
|
||||
default: A new release of the Modrinth App is available!
|
||||
type: string
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release Modrinth App
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [macos-latest, windows-latest, ubuntu-22.04]
|
||||
|
||||
env:
|
||||
LINUX_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-unknown-linux-gnu)
|
||||
WINDOWS_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-pc-windows-msvc)
|
||||
MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME: App bundle (universal-apple-darwin)
|
||||
LAUNCHER_FILES_BUCKET_BASE_URL: https://launcher-files.modrinth.com
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: 📥 Download Modrinth App artifacts
|
||||
uses: dawidd6/action-download-artifact@v11
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Rust setup (mac)
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
workflow: theseus-build.yml
|
||||
workflow_conclusion: success
|
||||
event: push
|
||||
branch: ${{ inputs.version-tag }}
|
||||
use_unzip: true
|
||||
rustflags: ''
|
||||
target: x86_64-apple-darwin
|
||||
|
||||
- name: 🛠️ Generate version manifest
|
||||
env:
|
||||
VERSION_TAG: ${{ inputs.version-tag }}
|
||||
RELEASE_NOTES: ${{ inputs.release-notes }}
|
||||
- name: Rust setup
|
||||
if: "!startsWith(matrix.platform, 'macos')"
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
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: |
|
||||
# Reference: https://tauri.app/plugin/updater/#server-support
|
||||
jq -nc \
|
||||
--arg versionTag "${VERSION_TAG#v}" \
|
||||
--arg releaseNotes "$RELEASE_NOTES" \
|
||||
--rawfile macOsAarch64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \
|
||||
--rawfile macOsX64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \
|
||||
--rawfile linuxX64UpdateArtifactSignature "${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/appimage/Modrinth App_${VERSION_TAG#v}_amd64.AppImage.tar.gz.sig" \
|
||||
--rawfile windowsX64UpdateArtifactSignature "${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/nsis/Modrinth App_${VERSION_TAG#v}_x64-setup.nsis.zip.sig" \
|
||||
'{
|
||||
"version": $versionTag,
|
||||
"notes": $releaseNotes,
|
||||
"pub_date": now | todateiso8601,
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
"signature": $macOsAarch64UpdateArtifactSignature,
|
||||
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App.app.tar.gz")",
|
||||
"install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App_" + $versionTag + "_universal.dmg")"]
|
||||
},
|
||||
"darwin-x86_64": {
|
||||
"signature": $macOsX64UpdateArtifactSignature,
|
||||
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App.app.tar.gz")",
|
||||
"install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App_" + $versionTag + "_universal.dmg")"]
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"signature": $linuxX64UpdateArtifactSignature,
|
||||
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.AppImage.tar.gz")",
|
||||
"install_urls": [
|
||||
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.deb")",
|
||||
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.AppImage")",
|
||||
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App-" + $versionTag + "-1.x86_64.rpm")"
|
||||
]
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"signature": $windowsX64UpdateArtifactSignature,
|
||||
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/windows/\("Modrinth App_" + $versionTag + "_x64-setup.nsis.zip")",
|
||||
"install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/windows/\("Modrinth App_" + $versionTag + "_x64-setup.exe")"]
|
||||
}
|
||||
}
|
||||
}' > updates.json
|
||||
corepack enable
|
||||
corepack prepare --activate
|
||||
|
||||
echo "Generated manifest for version ${VERSION_TAG}:"
|
||||
cat updates.json
|
||||
|
||||
- name: 📤 Upload release artifacts
|
||||
env:
|
||||
VERSION_TAG: ${{ inputs.version-tag }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.LAUNCHER_FILES_BUCKET_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.LAUNCHER_FILES_BUCKET_SECRET_ACCESS_KEY }}
|
||||
AWS_BUCKET: ${{ secrets.LAUNCHER_FILES_BUCKET_NAME }}
|
||||
AWS_REGION: ${{ secrets.LAUNCHER_FILES_BUCKET_REGION }}
|
||||
AWS_ENDPOINT_URL: ${{ secrets.LAUNCHER_FILES_BUCKET_ENDPOINT_URL }}
|
||||
AWS_PAGER: ''
|
||||
# Work around incompatible checksum behavior with some S3-like object storage providers,
|
||||
# such as Cloudflare R2. See:
|
||||
# - https://developers.cloudflare.com/r2/examples/aws/aws-cli/
|
||||
# - https://developers.cloudflare.com/r2/examples/aws/aws-sdk-java/
|
||||
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
|
||||
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
for macosBundleType in 'macos' 'dmg'; do
|
||||
aws s3 cp --recursive \
|
||||
"${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/${macosBundleType}" \
|
||||
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/macos"
|
||||
done
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
for linuxBundleType in 'appimage' 'deb' 'rpm'; do
|
||||
aws s3 cp --recursive \
|
||||
"${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${linuxBundleType}" \
|
||||
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/linux"
|
||||
done
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
for windowsBundleType in 'nsis'; do
|
||||
aws s3 cp --recursive \
|
||||
"${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${windowsBundleType}" \
|
||||
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/windows"
|
||||
done
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev pkg-config libayatana-appindicator3-dev librsvg2-dev
|
||||
|
||||
aws s3 cp updates.json "s3://${AWS_BUCKET}"
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: build app (macos)
|
||||
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config "tauri-release.conf.json"
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: build app
|
||||
run: pnpm --filter=@modrinth/app run tauri build --config "tauri-release.conf.json"
|
||||
id: build_os
|
||||
if: "!startsWith(matrix.platform, 'macos')"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: upload ${{ matrix.platform }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.platform }}
|
||||
path: |
|
||||
target/*/release/bundle/*/*.dmg
|
||||
target/*/release/bundle/*/*.app.tar.gz
|
||||
target/*/release/bundle/*/*.app.tar.gz.sig
|
||||
target/release/bundle/*/*.dmg
|
||||
target/release/bundle/*/*.app.tar.gz
|
||||
target/release/bundle/*/*.app.tar.gz.sig
|
||||
|
||||
target/release/bundle/*/*.AppImage
|
||||
target/release/bundle/*/*.AppImage.tar.gz
|
||||
target/release/bundle/*/*.AppImage.tar.gz.sig
|
||||
target/release/bundle/*/*.deb
|
||||
target/release/bundle/*/*.rpm
|
||||
|
||||
target/release/bundle/msi/*.msi
|
||||
target/release/bundle/msi/*.msi.zip
|
||||
target/release/bundle/msi/*.msi.zip.sig
|
||||
|
||||
target/release/bundle/nsis/*.exe
|
||||
target/release/bundle/nsis/*.nsis.zip
|
||||
target/release/bundle/nsis/*.nsis.zip.sig
|
||||
|
||||
11
.github/workflows/turbo-ci.yml
vendored
11
.github/workflows/turbo-ci.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
|
||||
# back to a cached cargo install
|
||||
- name: 🧰 Setup cargo-sqlx
|
||||
uses: taiki-e/cache-cargo-install-action@v2
|
||||
uses: AlexTMjugador/cache-cargo-install-action@feat/features-support
|
||||
with:
|
||||
tool: sqlx-cli
|
||||
locked: false
|
||||
@@ -74,14 +74,5 @@ jobs:
|
||||
cp .env.local .env
|
||||
sqlx database setup
|
||||
|
||||
- name: ⚙️ Set app environment
|
||||
working-directory: packages/app-lib
|
||||
run: cp .env.staging .env
|
||||
|
||||
- name: 🔍 Lint and test
|
||||
run: pnpm run ci
|
||||
|
||||
- name: 🔍 Verify intl:extract has been run
|
||||
run: |
|
||||
pnpm intl:extract
|
||||
git diff --exit-code --color */*/src/locales/en-US/index.json
|
||||
|
||||
1
.idea/code.iml
generated
1
.idea/code.iml
generated
@@ -10,6 +10,7 @@
|
||||
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/rust-common/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||
</content>
|
||||
|
||||
2
.idea/modules.xml
generated
2
.idea/modules.xml
generated
@@ -5,4 +5,4 @@
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/code.iml" filepath="$PROJECT_DIR$/.idea/code.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
774
Cargo.lock
generated
774
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
50
Cargo.toml
50
Cargo.toml
@@ -25,7 +25,7 @@ actix-ws = "0.3.0"
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
ariadne = { path = "packages/ariadne" }
|
||||
async_zip = "0.0.17"
|
||||
async-compression = { version = "0.4.25", default-features = false }
|
||||
async-compression = { version = "0.4.24", default-features = false }
|
||||
async-recursion = "1.1.1"
|
||||
async-stripe = { version = "0.41.0", default-features = false, features = [
|
||||
"runtime-tokio-hyper-rustls",
|
||||
@@ -37,7 +37,6 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [
|
||||
async-walkdir = "2.1.0"
|
||||
base64 = "0.22.1"
|
||||
bitflags = "2.9.1"
|
||||
bytemuck = "1.23.0"
|
||||
bytes = "1.10.1"
|
||||
censor = "0.3.0"
|
||||
chardetng = "0.1.17"
|
||||
@@ -48,7 +47,6 @@ color-thief = "0.2.2"
|
||||
console-subscriber = "0.4.1"
|
||||
daedalus = { path = "packages/daedalus" }
|
||||
dashmap = "6.1.0"
|
||||
data-url = "0.3.1"
|
||||
deadpool-redis = "0.21.1"
|
||||
dirs = "6.0.0"
|
||||
discord-rich-presence = "0.2.5"
|
||||
@@ -62,18 +60,10 @@ flate2 = "1.1.2"
|
||||
fs4 = { version = "0.13.1", default-features = false }
|
||||
futures = { version = "0.3.31", default-features = false }
|
||||
futures-util = "0.3.31"
|
||||
hashlink = "0.10.0"
|
||||
heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
hickory-resolver = "0.25.2"
|
||||
hmac = "0.12.1"
|
||||
hyper = "1.6.0"
|
||||
hyper-rustls = { version = "0.27.7", default-features = false, features = [
|
||||
"http1",
|
||||
"native-tokio",
|
||||
"ring",
|
||||
"tls12",
|
||||
] }
|
||||
hyper-tls = "0.6.0"
|
||||
hyper-util = "0.1.14"
|
||||
iana-time-zone = "0.1.63"
|
||||
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
|
||||
@@ -99,8 +89,6 @@ notify = { version = "8.0.0", default-features = false }
|
||||
notify-debouncer-mini = { version = "0.6.0", default-features = false }
|
||||
p256 = "0.13.2"
|
||||
paste = "1.0.15"
|
||||
phf = { version = "0.12.1", features = ["macros"] }
|
||||
png = "0.17.16"
|
||||
prometheus = "0.14.0"
|
||||
quartz_nbt = "0.2.9"
|
||||
quick-xml = "0.37.5"
|
||||
@@ -108,9 +96,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
|
||||
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
|
||||
regex = "1.11.1"
|
||||
reqwest = { version = "0.12.20", default-features = false }
|
||||
rgb = "0.8.50"
|
||||
rust_decimal = { version = "1.37.2", features = [
|
||||
reqwest = { version = "0.12.19", default-features = false }
|
||||
rust_decimal = { version = "1.37.1", features = [
|
||||
"serde-with-float",
|
||||
"serde-with-str",
|
||||
] }
|
||||
@@ -121,7 +108,7 @@ rust-s3 = { version = "0.35.1", default-features = false, features = [
|
||||
"tokio-rustls-tls",
|
||||
] }
|
||||
rusty-money = "0.4.1"
|
||||
sentry = { version = "0.41.0", default-features = false, features = [
|
||||
sentry = { version = "0.38.1", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
"debug-images",
|
||||
@@ -129,13 +116,13 @@ sentry = { version = "0.41.0", default-features = false, features = [
|
||||
"reqwest",
|
||||
"rustls",
|
||||
] }
|
||||
sentry-actix = "0.41.0"
|
||||
sentry-actix = "0.38.1"
|
||||
serde = "1.0.219"
|
||||
serde_bytes = "0.11.17"
|
||||
serde_cbor = "0.11.2"
|
||||
serde_ini = "0.2.0"
|
||||
serde_json = "1.0.140"
|
||||
serde_with = "3.13.0"
|
||||
serde_with = "3.12.0"
|
||||
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
|
||||
sha1 = "0.10.6"
|
||||
sha1_smol = { version = "1.0.1", features = ["std"] }
|
||||
@@ -144,19 +131,18 @@ spdx = "0.10.8"
|
||||
sqlx = { version = "0.8.6", default-features = false }
|
||||
sysinfo = { version = "0.35.2", default-features = false }
|
||||
tar = "0.4.44"
|
||||
tauri = "2.6.1"
|
||||
tauri-build = "2.3.0"
|
||||
tauri-plugin-deep-link = "2.4.0"
|
||||
tauri-plugin-dialog = "2.3.0"
|
||||
tauri-plugin-http = "2.5.0"
|
||||
tauri-plugin-opener = "2.4.0"
|
||||
tauri-plugin-os = "2.3.0"
|
||||
tauri-plugin-single-instance = "2.3.0"
|
||||
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
|
||||
tauri = "2.5.1"
|
||||
tauri-build = "2.2.0"
|
||||
tauri-plugin-deep-link = "2.3.0"
|
||||
tauri-plugin-dialog = "2.2.2"
|
||||
tauri-plugin-opener = "2.2.7"
|
||||
tauri-plugin-os = "2.2.1"
|
||||
tauri-plugin-single-instance = "2.2.4"
|
||||
tauri-plugin-updater = { version = "2.7.1", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
"zip",
|
||||
] }
|
||||
tauri-plugin-window-state = "2.3.0"
|
||||
tauri-plugin-window-state = "2.2.2"
|
||||
tempfile = "3.20.0"
|
||||
theseus = { path = "packages/app-lib" }
|
||||
thiserror = "2.0.12"
|
||||
@@ -179,7 +165,7 @@ whoami = "1.6.0"
|
||||
winreg = "0.55.0"
|
||||
woothee = "0.13.0"
|
||||
yaserde = "0.12.0"
|
||||
zip = { version = "4.2.0", default-features = false, features = [
|
||||
zip = { version = "4.0.0", default-features = false, features = [
|
||||
"bzip2",
|
||||
"deflate",
|
||||
"deflate64",
|
||||
@@ -226,7 +212,7 @@ wildcard_dependencies = "warn"
|
||||
warnings = "deny"
|
||||
|
||||
[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
|
||||
[profile.release]
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
**/dist
|
||||
*.gltf
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@modrinth/app-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0-local",
|
||||
"version": "0.9.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,7 +9,7 @@
|
||||
"tsc:check": "vue-tsc --noEmit",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write .",
|
||||
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
||||
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
||||
"test": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -20,20 +20,16 @@
|
||||
"@sentry/vue": "^8.27.0",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||
"@tauri-apps/plugin-http": "^2.5.0",
|
||||
"@tauri-apps/plugin-opener": "^2.2.6",
|
||||
"@tauri-apps/plugin-os": "^2.2.1",
|
||||
"@tauri-apps/plugin-updater": "^2.7.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.2.2",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"floating-vue": "^5.2.2",
|
||||
"ofetch": "^1.3.4",
|
||||
"pinia": "^2.1.7",
|
||||
"posthog-js": "^1.158.2",
|
||||
"three": "^0.172.0",
|
||||
"vite-svg-loader": "^5.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-multiselect": "3.0.0",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<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 {
|
||||
ArrowBigUpDashIcon,
|
||||
ChangeSkinIcon,
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
HomeIcon,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
SettingsIcon,
|
||||
WorldIcon,
|
||||
XIcon,
|
||||
NewspaperIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
@@ -27,7 +25,7 @@ import {
|
||||
ButtonStyled,
|
||||
Notifications,
|
||||
OverflowMenu,
|
||||
NewsArticleCard,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import { useLoading, useTheming } from '@/store/state'
|
||||
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
||||
@@ -61,17 +59,17 @@ import { renderString } from '@modrinth/utils'
|
||||
import { useFetch } from '@/helpers/fetch.js'
|
||||
import { check } from '@tauri-apps/plugin-updater'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||
import { get_user } from '@/helpers/cache.js'
|
||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
||||
import dayjs from 'dayjs'
|
||||
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
|
||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
import { get_available_capes, get_available_skins } from './helpers/skins'
|
||||
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -179,7 +177,6 @@ async function setupApp() {
|
||||
'criticalAnnouncements',
|
||||
true,
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((res) => {
|
||||
if (res && res.header && res.body) {
|
||||
criticalErrorMessage.value = res
|
||||
@@ -191,35 +188,15 @@ async function setupApp() {
|
||||
)
|
||||
})
|
||||
|
||||
useFetch(`https://modrinth.com/news/feed/articles.json`, 'news', true)
|
||||
.then((response) => response.json())
|
||||
.then((res) => {
|
||||
if (res && res.articles) {
|
||||
// Format expected by NewsArticleCard component.
|
||||
news.value = res.articles
|
||||
.map((article) => ({
|
||||
...article,
|
||||
path: article.link,
|
||||
thumbnail: article.thumbnail,
|
||||
title: article.title,
|
||||
summary: article.summary,
|
||||
date: article.date,
|
||||
}))
|
||||
.slice(0, 4)
|
||||
}
|
||||
})
|
||||
useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => {
|
||||
if (res && res.articles) {
|
||||
news.value = res.articles
|
||||
}
|
||||
})
|
||||
|
||||
get_opening_command().then(handleCommand)
|
||||
checkUpdates()
|
||||
fetchCredentials()
|
||||
|
||||
try {
|
||||
const skins = (await get_available_skins()) ?? []
|
||||
const capes = (await get_available_capes()) ?? []
|
||||
generateSkinPreviews(skins, capes)
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate skin previews in app setup.', error)
|
||||
}
|
||||
}
|
||||
|
||||
const stateFailed = ref(false)
|
||||
@@ -264,8 +241,6 @@ const incompatibilityWarningModal = ref()
|
||||
|
||||
const credentials = ref()
|
||||
|
||||
const modrinthLoginFlowWaitModal = ref()
|
||||
|
||||
async function fetchCredentials() {
|
||||
const creds = await getCreds().catch(handleError)
|
||||
if (creds && creds.user_id) {
|
||||
@@ -275,24 +250,8 @@ async function fetchCredentials() {
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
modrinthLoginFlowWaitModal.value.show()
|
||||
|
||||
try {
|
||||
await login()
|
||||
await fetchCredentials()
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
typeof error['message'] === 'string' &&
|
||||
error.message.includes('Login canceled')
|
||||
) {
|
||||
// Not really an error due to being a result of user interaction, show nothing
|
||||
} else {
|
||||
handleError(error)
|
||||
}
|
||||
} finally {
|
||||
modrinthLoginFlowWaitModal.value.hide()
|
||||
}
|
||||
await login().catch(handleError)
|
||||
await fetchCredentials()
|
||||
}
|
||||
|
||||
async function logOut() {
|
||||
@@ -345,7 +304,6 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const accounts = ref(null)
|
||||
provide('accountsCard', accounts)
|
||||
|
||||
command_listener(handleCommand)
|
||||
async function handleCommand(e) {
|
||||
@@ -421,9 +379,6 @@ function handleAuxClick(e) {
|
||||
<Suspense>
|
||||
<AppSettingsModal ref="settingsModal" />
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<InstanceCreationModal ref="installationModal" />
|
||||
</Suspense>
|
||||
@@ -444,9 +399,6 @@ function handleAuxClick(e) {
|
||||
>
|
||||
<CompassIcon />
|
||||
</NavButton>
|
||||
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
|
||||
<ChangeSkinIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="'Library'"
|
||||
to="/library"
|
||||
@@ -507,13 +459,13 @@ function handleAuxClick(e) {
|
||||
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
|
||||
<div class="flex items-center gap-1 ml-3">
|
||||
<button
|
||||
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||
@click="router.back()"
|
||||
>
|
||||
<LeftArrowIcon />
|
||||
</button>
|
||||
<button
|
||||
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||
@click="router.forward()"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
@@ -627,20 +579,34 @@ function handleAuxClick(e) {
|
||||
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
|
||||
</suspense>
|
||||
</div>
|
||||
<div v-if="news && news.length > 0" class="pt-4 flex flex-col items-center">
|
||||
<h3 class="px-4 text-lg m-0 text-left w-full">News</h3>
|
||||
<div class="px-4 pt-2 space-y-4 flex flex-col items-center w-full">
|
||||
<NewsArticleCard
|
||||
v-for="(item, index) in news"
|
||||
:key="`news-${index}`"
|
||||
:article="item"
|
||||
<div v-if="news && news.length > 0" class="pt-4 flex flex-col">
|
||||
<h3 class="px-4 text-lg m-0">News</h3>
|
||||
<template v-for="(item, index) in news" :key="`news-${index}`">
|
||||
<a
|
||||
:class="`flex flex-col outline-offset-[-4px] hover:bg-[--brand-gradient-border] focus:bg-[--brand-gradient-border] px-4 transition-colors ${index === 0 ? 'pt-2 pb-4' : 'py-4'}`"
|
||||
:href="item.link"
|
||||
target="_blank"
|
||||
rel="external"
|
||||
>
|
||||
<img
|
||||
:src="item.thumbnail"
|
||||
alt="News thumbnail"
|
||||
aria-hidden="true"
|
||||
class="w-full aspect-[3/1] object-cover rounded-2xl border-[1px] border-solid border-[--brand-gradient-border]"
|
||||
/>
|
||||
<h4 class="mt-2 mb-0 text-sm leading-none text-contrast font-semibold">
|
||||
{{ item.title }}
|
||||
</h4>
|
||||
<p class="my-1 text-sm text-secondary leading-tight">{{ item.summary }}</p>
|
||||
<p class="text-right text-sm text-secondary opacity-60 leading-tight m-0">
|
||||
{{ 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">
|
||||
<a href="https://modrinth.com/news" target="_blank" class="my-4">
|
||||
<NewspaperIcon /> View all news
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</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 |
@@ -136,7 +136,7 @@ const filteredResults = computed(() => {
|
||||
|
||||
if (sortBy.value === 'Game version') {
|
||||
instances.sort((a, b) => {
|
||||
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
|
||||
return a.game_version.localeCompare(b.game_version)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -213,17 +213,6 @@ const filteredResults = computed(() => {
|
||||
instanceMap.set(entry[0], entry[1])
|
||||
})
|
||||
}
|
||||
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
|
||||
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
|
||||
if (group.value === 'Game version') {
|
||||
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
|
||||
return a[0].localeCompare(b[0], undefined, { numeric: true })
|
||||
})
|
||||
instanceMap.clear()
|
||||
sortedEntries.forEach((entry) => {
|
||||
instanceMap.set(entry[0], entry[1])
|
||||
})
|
||||
}
|
||||
|
||||
return instanceMap
|
||||
})
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
<Avatar
|
||||
size="36px"
|
||||
:src="
|
||||
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
selectedAccount
|
||||
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
|
||||
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
"
|
||||
/>
|
||||
<div class="flex flex-col w-full">
|
||||
<span>{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}</span>
|
||||
<span>{{ selectedAccount ? selectedAccount.username : 'Select account' }}</span>
|
||||
<span class="text-secondary text-xs">Minecraft account</span>
|
||||
</div>
|
||||
<DropdownIcon class="w-5 h-5 shrink-0" />
|
||||
@@ -26,40 +28,28 @@
|
||||
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
|
||||
>
|
||||
<div v-if="selectedAccount" class="selected account">
|
||||
<Avatar size="xs" :src="avatarUrl" />
|
||||
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.id}/128`" />
|
||||
<div>
|
||||
<h4>{{ selectedAccount.profile.name }}</h4>
|
||||
<h4>{{ selectedAccount.username }}</h4>
|
||||
<p>Selected</p>
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="'Log out'"
|
||||
icon-only
|
||||
color="raised"
|
||||
@click="logout(selectedAccount.profile.id)"
|
||||
>
|
||||
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.id)">
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="logged-out account">
|
||||
<h4>Not signed in</h4>
|
||||
<Button
|
||||
v-tooltip="'Log in'"
|
||||
:disabled="loginDisabled"
|
||||
icon-only
|
||||
color="primary"
|
||||
@click="login()"
|
||||
>
|
||||
<LogInIcon v-if="!loginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
<Button v-tooltip="'Log in'" icon-only color="primary" @click="login()">
|
||||
<LogInIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="displayAccounts.length > 0" class="account-group">
|
||||
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
|
||||
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
|
||||
<Button class="option account" @click="setAccount(account)">
|
||||
<Avatar :src="getAccountAvatarUrl(account)" class="icon" />
|
||||
<p>{{ account.profile.name }}</p>
|
||||
<Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
|
||||
<p>{{ account.username }}</p>
|
||||
</Button>
|
||||
<Button v-tooltip="'Log out'" icon-only @click="logout(account.profile.id)">
|
||||
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -73,7 +63,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon } from '@modrinth/assets'
|
||||
import { Avatar, Button, Card } from '@modrinth/ui'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||
import {
|
||||
@@ -87,8 +77,6 @@ import { handleError } from '@/store/state.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { get_available_skins } from '@/helpers/skins'
|
||||
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
|
||||
defineProps({
|
||||
mode: {
|
||||
@@ -101,86 +89,32 @@ defineProps({
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const accounts = ref({})
|
||||
const loginDisabled = ref(false)
|
||||
const defaultUser = ref()
|
||||
const equippedSkin = ref(null)
|
||||
const headUrlCache = ref(new Map())
|
||||
|
||||
async function refreshValues() {
|
||||
defaultUser.value = await get_default_user().catch(handleError)
|
||||
accounts.value = await users().catch(handleError)
|
||||
|
||||
try {
|
||||
const skins = await get_available_skins()
|
||||
equippedSkin.value = skins.find((skin) => skin.is_equipped)
|
||||
|
||||
if (equippedSkin.value) {
|
||||
try {
|
||||
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
|
||||
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
|
||||
} catch (error) {
|
||||
console.warn('Failed to get head render for equipped skin:', error)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
equippedSkin.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function setLoginDisabled(value) {
|
||||
loginDisabled.value = value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refreshValues,
|
||||
setLoginDisabled,
|
||||
loginDisabled,
|
||||
})
|
||||
await refreshValues()
|
||||
|
||||
const displayAccounts = computed(() =>
|
||||
accounts.value.filter((account) => defaultUser.value !== account.profile.id),
|
||||
accounts.value.filter((account) => defaultUser.value !== account.id),
|
||||
)
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
if (equippedSkin.value?.texture_key) {
|
||||
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
|
||||
if (cachedUrl) {
|
||||
return cachedUrl
|
||||
}
|
||||
return `https://mc-heads.net/avatar/${equippedSkin.value.texture_key}/128`
|
||||
}
|
||||
if (selectedAccount.value?.profile?.id) {
|
||||
return `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128`
|
||||
}
|
||||
return 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
})
|
||||
|
||||
function getAccountAvatarUrl(account) {
|
||||
if (
|
||||
account.profile.id === selectedAccount.value?.profile?.id &&
|
||||
equippedSkin.value?.texture_key
|
||||
) {
|
||||
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
|
||||
if (cachedUrl) {
|
||||
return cachedUrl
|
||||
}
|
||||
}
|
||||
return `https://mc-heads.net/avatar/${account.profile.id}/128`
|
||||
}
|
||||
|
||||
const selectedAccount = computed(() =>
|
||||
accounts.value.find((account) => account.profile.id === defaultUser.value),
|
||||
accounts.value.find((account) => account.id === defaultUser.value),
|
||||
)
|
||||
|
||||
async function setAccount(account) {
|
||||
defaultUser.value = account.profile.id
|
||||
await set_default_user(account.profile.id).catch(handleError)
|
||||
defaultUser.value = account.id
|
||||
await set_default_user(account.id).catch(handleError)
|
||||
emit('change')
|
||||
}
|
||||
|
||||
async function login() {
|
||||
loginDisabled.value = true
|
||||
const loggedIn = await login_flow().catch(handleSevereError)
|
||||
|
||||
if (loggedIn) {
|
||||
@@ -189,7 +123,6 @@ async function login() {
|
||||
}
|
||||
|
||||
trackEvent('AccountLogIn')
|
||||
loginDisabled.value = false
|
||||
}
|
||||
|
||||
const logout = async (id) => {
|
||||
|
||||
@@ -92,7 +92,7 @@ async function loginMinecraft() {
|
||||
const loggedIn = await login_flow()
|
||||
|
||||
if (loggedIn) {
|
||||
await set_default_user(loggedIn.profile.id).catch(handleError)
|
||||
await set_default_user(loggedIn.id).catch(handleError)
|
||||
}
|
||||
|
||||
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
||||
@@ -219,8 +219,8 @@ async function copyToClipboard(text) {
|
||||
<template v-else-if="metadata.notEnoughSpace">
|
||||
<h3>Not enough space</h3>
|
||||
<p>
|
||||
It looks like there is not enough space on the disk containing the directory you
|
||||
selected. Please free up some space and try again or cancel the directory change.
|
||||
It looks like there is not enough space on the disk containing the dirctory you
|
||||
selected Please free up some space and try again or cancel the directory change.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { showProfileInFolder } from '@/helpers/utils.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import dayjs from 'dayjs'
|
||||
import { formatCategory } from '@modrinth/utils'
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
@@ -172,10 +173,7 @@ onUnmounted(() => unlisten())
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
|
||||
<TimerIcon />
|
||||
<span class="text-sm">
|
||||
<template v-if="instance.last_played">
|
||||
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,8 +237,8 @@ onUnmounted(() => unlisten())
|
||||
</p>
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
|
||||
<GameIcon class="shrink-0" />
|
||||
<span class="text-sm capitalize">
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
<span class="text-sm">
|
||||
{{ formatCategory(instance.loader) }} {{ instance.game_version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -305,16 +305,12 @@ const [
|
||||
get_game_versions().then(shallowRef).catch(handleError),
|
||||
get_loaders()
|
||||
.then((value) =>
|
||||
ref(
|
||||
value
|
||||
.filter((item) => item.supported_project_types.includes('modpack'))
|
||||
.map((item) => item.name.toLowerCase()),
|
||||
),
|
||||
value
|
||||
.filter((item) => item.supported_project_types.includes('modpack'))
|
||||
.map((item) => item.name.toLowerCase()),
|
||||
)
|
||||
.catch((err) => {
|
||||
handleError(err)
|
||||
return ref([])
|
||||
}),
|
||||
.then(ref)
|
||||
.catch(handleError),
|
||||
])
|
||||
loaders.value.unshift('vanilla')
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ async function testJava() {
|
||||
testingJava.value = true
|
||||
testingJavaSuccess.value = await test_jre(
|
||||
props.modelValue ? props.modelValue.path : '',
|
||||
1,
|
||||
props.version,
|
||||
)
|
||||
testingJava.value = false
|
||||
@@ -126,7 +127,7 @@ async function handleJavaFileInput() {
|
||||
const filePath = await open()
|
||||
|
||||
if (filePath) {
|
||||
let result = await get_jre(filePath.path ?? filePath).catch(handleError)
|
||||
let result = await get_jre(filePath.path ?? filePath)
|
||||
if (!result) {
|
||||
result = {
|
||||
path: filePath.path ?? filePath,
|
||||
|
||||
@@ -21,11 +21,14 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const featuredCategory = computed(() => {
|
||||
if (props.project.display_categories.includes('optimization')) {
|
||||
if (props.project.categories.includes('optimization')) {
|
||||
return 'optimization'
|
||||
}
|
||||
|
||||
return props.project.display_categories[0] ?? props.project.categories[0]
|
||||
if (props.project.categories.length > 0) {
|
||||
return props.project.categories[0]
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const toColor = computed(() => {
|
||||
|
||||
@@ -32,33 +32,8 @@ function updateAdPosition() {
|
||||
|
||||
<template>
|
||||
<div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
|
||||
<a
|
||||
href="https://modrinth.gg?from=app-placeholder"
|
||||
target="_blank"
|
||||
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
|
||||
>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
class="hidden light-image rounded-[inherit]"
|
||||
/>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
class="dark-image rounded-[inherit]"
|
||||
/>
|
||||
</a>
|
||||
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
|
||||
<p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.light,
|
||||
.light-mode {
|
||||
.dark-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.light-image {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,7 +30,7 @@ const getInstances = async () => {
|
||||
|
||||
return dateB - dateA
|
||||
})
|
||||
.slice(0, 3)
|
||||
.slice(0, 4)
|
||||
}
|
||||
|
||||
await getInstances()
|
||||
|
||||
@@ -76,10 +76,10 @@ const installing = ref(false)
|
||||
const onInstall = ref(() => {})
|
||||
|
||||
defineExpose({
|
||||
show: (instanceVal, projectVal, projectVersions, selected, callback) => {
|
||||
show: (instanceVal, projectVal, projectVersions, callback) => {
|
||||
instance.value = instanceVal
|
||||
versions.value = projectVersions
|
||||
selectedVersion.value = selected ?? projectVersions[0]
|
||||
selectedVersion.value = projectVersions[0]
|
||||
|
||||
project.value = projectVal
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ import { edit, get_optimal_jre_key } from '@/helpers/profile'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||
import { get_max_memory } from '@/helpers/jre'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -34,7 +34,7 @@ const envVars = ref(
|
||||
|
||||
const overrideMemorySettings = ref(!!props.instance.memory)
|
||||
const memory = ref(props.instance.memory ?? globalSettings.memory)
|
||||
const { maxMemory, snapPoints } = await useMemorySlider()
|
||||
const maxMemory = Math.floor((await get_max_memory().catch(handleError)) / 1024)
|
||||
|
||||
const editProfileObject = computed(() => {
|
||||
const editProfile: {
|
||||
@@ -156,8 +156,6 @@ const messages = defineMessages({
|
||||
:min="512"
|
||||
:max="maxMemory"
|
||||
:step="64"
|
||||
:snap-points="snapPoints"
|
||||
:snap-range="512"
|
||||
unit="MB"
|
||||
/>
|
||||
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ShieldIcon,
|
||||
SettingsIcon,
|
||||
GaugeIcon,
|
||||
PaintbrushIcon,
|
||||
PaintBrushIcon,
|
||||
GameIcon,
|
||||
CoffeeIcon,
|
||||
} from '@modrinth/assets'
|
||||
@@ -41,7 +41,7 @@ const tabs = [
|
||||
id: 'app.settings.tabs.appearance',
|
||||
defaultMessage: 'Appearance',
|
||||
}),
|
||||
icon: PaintbrushIcon,
|
||||
icon: PaintBrushIcon,
|
||||
content: AppearanceSettings,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
defineProps({
|
||||
onFlowCancel: {
|
||||
type: Function,
|
||||
default() {
|
||||
return async () => {}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref()
|
||||
|
||||
function show() {
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @hide="onFlowCancel">
|
||||
<template #title>
|
||||
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
|
||||
<LogInIcon /> Sign in
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-center gap-2">
|
||||
<SpinnerIcon class="w-12 h-12 animate-spin" />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
Please sign in at the browser window that just opened to continue.
|
||||
</p>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { NewModal as Modal } from '@modrinth/ui'
|
||||
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
|
||||
import { useTheming } from '@/store/theme.ts'
|
||||
@@ -26,16 +26,16 @@ const props = defineProps({
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
const modal = useTemplateRef('modal')
|
||||
const modal = ref(null)
|
||||
|
||||
defineExpose({
|
||||
show: (e: MouseEvent) => {
|
||||
show: () => {
|
||||
hide_ads_window()
|
||||
modal.value?.show(e)
|
||||
modal.value.show()
|
||||
},
|
||||
hide: () => {
|
||||
onModalHide()
|
||||
modal.value?.hide()
|
||||
modal.value.hide()
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -56,17 +56,9 @@ watch(
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2>
|
||||
<p class="m-0 mt-1">Disables the nametag above your player on the skins page.</p>
|
||||
</div>
|
||||
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
|
||||
</div>
|
||||
|
||||
<div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Native decorations</h2>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Native Decorations</h2>
|
||||
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
|
||||
</div>
|
||||
<Toggle id="native-decorations" v-model="settings.native_decorations" />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { ref, watch } from 'vue'
|
||||
import { get_max_memory } from '@/helpers/jre'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { Slider, Toggle } from '@modrinth/ui'
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
|
||||
const fetchSettings = await get()
|
||||
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
|
||||
@@ -10,7 +11,7 @@ fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).jo
|
||||
|
||||
const settings = ref(fetchSettings)
|
||||
|
||||
const { maxMemory, snapPoints } = await useMemorySlider()
|
||||
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
|
||||
|
||||
watch(
|
||||
settings,
|
||||
@@ -106,8 +107,6 @@ watch(
|
||||
:min="512"
|
||||
:max="maxMemory"
|
||||
:step="64"
|
||||
:snap-points="snapPoints"
|
||||
:snap-range="512"
|
||||
unit="MB"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
EyeIcon,
|
||||
@@ -43,7 +42,6 @@ const emit = defineEmits<{
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
last_played: Dayjs
|
||||
}>()
|
||||
|
||||
const loadingModpack = ref(!!props.instance.linked_data)
|
||||
@@ -149,12 +147,12 @@ onUnmounted(() => {
|
||||
: null
|
||||
"
|
||||
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, {
|
||||
time: formatRelativeTime(last_played.toISOString?.()),
|
||||
time: formatRelativeTime(instance.last_played.toISOString()),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type ProtocolVersion,
|
||||
type ServerWorld,
|
||||
type ServerData,
|
||||
type WorldWithProfile,
|
||||
@@ -34,7 +33,7 @@ const theme = useTheming()
|
||||
|
||||
const jumpBackInItems = ref<JumpBackInItem[]>([])
|
||||
const serverData = ref<Record<string, ServerData>>({})
|
||||
const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
|
||||
const protocolVersions = ref<Record<string, number | null>>({})
|
||||
|
||||
const MIN_JUMP_BACK_IN = 3
|
||||
const MAX_JUMP_BACK_IN = 6
|
||||
@@ -85,7 +84,7 @@ async function populateJumpBackIn() {
|
||||
|
||||
worldItems.push({
|
||||
type: 'world',
|
||||
last_played: dayjs(world.last_played ?? 0),
|
||||
last_played: dayjs(world.last_played),
|
||||
world: world,
|
||||
instance: instance,
|
||||
})
|
||||
@@ -122,8 +121,11 @@ async function populateJumpBackIn() {
|
||||
}
|
||||
})
|
||||
|
||||
servers.forEach(({ instancePath, address }) =>
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
|
||||
// fetch each server's data
|
||||
Promise.all(
|
||||
servers.map(({ instancePath, address }) =>
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,20 +138,20 @@ async function populateJumpBackIn() {
|
||||
|
||||
instanceItems.push({
|
||||
type: 'instance',
|
||||
last_played: dayjs(instance.last_played ?? 0),
|
||||
last_played: dayjs(instance.last_played),
|
||||
instance: instance,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO))
|
||||
.slice(0, MAX_JUMP_BACK_IN)
|
||||
}
|
||||
|
||||
function refreshServer(address: string, instancePath: string) {
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
|
||||
async function refreshServer(address: string, instancePath: string) {
|
||||
await refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
|
||||
}
|
||||
|
||||
async function joinWorld(world: WorldWithProfile) {
|
||||
@@ -289,7 +291,7 @@ onUnmounted(() => {
|
||||
"
|
||||
@stop="() => stopInstance(item.instance.path)"
|
||||
/>
|
||||
<InstanceItem v-else :instance="item.instance" :last_played="item.last_played" />
|
||||
<InstanceItem v-else :instance="item.instance" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import type {
|
||||
ProtocolVersion,
|
||||
ServerStatus,
|
||||
ServerWorld,
|
||||
SingleplayerWorld,
|
||||
World,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts'
|
||||
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
|
||||
import { formatNumber, getPingLevel } from '@modrinth/utils'
|
||||
import {
|
||||
@@ -60,9 +54,8 @@ const props = withDefaults(
|
||||
playingInstance?: boolean
|
||||
playingWorld?: boolean
|
||||
startingInstance?: boolean
|
||||
supportsServerQuickPlay?: boolean
|
||||
supportsWorldQuickPlay?: boolean
|
||||
currentProtocol?: ProtocolVersion | null
|
||||
supportsQuickPlay?: boolean
|
||||
currentProtocol?: number | null
|
||||
highlighted?: boolean
|
||||
|
||||
// Server only
|
||||
@@ -85,8 +78,7 @@ const props = withDefaults(
|
||||
playingInstance: false,
|
||||
playingWorld: false,
|
||||
startingInstance: false,
|
||||
supportsServerQuickPlay: true,
|
||||
supportsWorldQuickPlay: false,
|
||||
supportsQuickPlay: false,
|
||||
currentProtocol: null,
|
||||
|
||||
refreshing: false,
|
||||
@@ -110,8 +102,7 @@ const serverIncompatible = computed(
|
||||
!!props.serverStatus &&
|
||||
!!props.serverStatus.version?.protocol &&
|
||||
!!props.currentProtocol &&
|
||||
(props.serverStatus.version.protocol !== props.currentProtocol.version ||
|
||||
props.serverStatus.version.legacy !== props.currentProtocol.legacy),
|
||||
props.serverStatus.version.protocol !== props.currentProtocol,
|
||||
)
|
||||
|
||||
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
||||
@@ -129,26 +120,14 @@ const messages = defineMessages({
|
||||
id: 'instance.worlds.a_minecraft_server',
|
||||
defaultMessage: 'A Minecraft Server',
|
||||
},
|
||||
noServerQuickPlay: {
|
||||
id: 'instance.worlds.no_server_quick_play',
|
||||
defaultMessage: 'You can only jump straight into servers on Minecraft Alpha 1.0.5+',
|
||||
},
|
||||
noSingleplayerQuickPlay: {
|
||||
id: 'instance.worlds.no_singleplayer_quick_play',
|
||||
defaultMessage: 'You can only jump straight into singleplayer worlds on Minecraft 1.20+',
|
||||
noQuickPlay: {
|
||||
id: 'instance.worlds.no_quick_play',
|
||||
defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+',
|
||||
},
|
||||
gameAlreadyOpen: {
|
||||
id: 'instance.worlds.game_already_open',
|
||||
defaultMessage: 'Instance is already open',
|
||||
},
|
||||
noContact: {
|
||||
id: 'instance.worlds.no_contact',
|
||||
defaultMessage: "Server couldn't be contacted",
|
||||
},
|
||||
incompatibleServer: {
|
||||
id: 'instance.worlds.incompatible_server',
|
||||
defaultMessage: 'Server is incompatible',
|
||||
},
|
||||
copyAddress: {
|
||||
id: 'instance.worlds.copy_address',
|
||||
defaultMessage: 'Copy address',
|
||||
@@ -157,6 +136,10 @@ const messages = defineMessages({
|
||||
id: 'instance.worlds.view_instance',
|
||||
defaultMessage: 'View instance',
|
||||
},
|
||||
playAnyway: {
|
||||
id: 'instance.worlds.play_anyway',
|
||||
defaultMessage: 'Play anyway',
|
||||
},
|
||||
playInstance: {
|
||||
id: 'instance.worlds.play_instance',
|
||||
defaultMessage: 'Play instance',
|
||||
@@ -319,40 +302,39 @@ const messages = defineMessages({
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
||||
<ButtonStyled
|
||||
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
||||
color="red"
|
||||
>
|
||||
<button @click="emit('stop')">
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button
|
||||
v-tooltip="
|
||||
world.type == 'server' && !supportsServerQuickPlay
|
||||
? formatMessage(messages.noServerQuickPlay)
|
||||
: world.type == 'singleplayer' && !supportsWorldQuickPlay
|
||||
? formatMessage(messages.noSingleplayerQuickPlay)
|
||||
: playingOtherWorld || locked
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: !serverStatus
|
||||
? formatMessage(messages.noContact)
|
||||
: serverIncompatible
|
||||
? formatMessage(messages.incompatibleServer)
|
||||
: null
|
||||
"
|
||||
:disabled="
|
||||
playingOtherWorld ||
|
||||
startingInstance ||
|
||||
(world.type == 'server' && !supportsServerQuickPlay) ||
|
||||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
|
||||
"
|
||||
@click="emit('play')"
|
||||
<template v-if="world.type === 'singleplayer' || serverStatus">
|
||||
<ButtonStyled
|
||||
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
||||
color="red"
|
||||
>
|
||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||
<PlayIcon v-else aria-hidden="true" />
|
||||
<button @click="emit('stop')">
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button
|
||||
v-tooltip="
|
||||
serverIncompatible
|
||||
? 'Server is incompatible'
|
||||
: !supportsQuickPlay
|
||||
? formatMessage(messages.noQuickPlay)
|
||||
: playingOtherWorld || locked
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: null
|
||||
"
|
||||
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||
<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) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -365,6 +347,11 @@ const messages = defineMessages({
|
||||
disabled: playingInstance,
|
||||
action: () => emit('play-instance'),
|
||||
},
|
||||
{
|
||||
id: 'play-anyway',
|
||||
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
|
||||
action: () => emit('play'),
|
||||
},
|
||||
{
|
||||
id: 'open-instance',
|
||||
shown: !!instancePath,
|
||||
@@ -430,6 +417,10 @@ const messages = defineMessages({
|
||||
<PlayIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.playInstance) }}
|
||||
</template>
|
||||
<template #play-anyway>
|
||||
<PlayIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.playAnyway) }}
|
||||
</template>
|
||||
<template #open-instance>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.viewInstance) }}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { fetch } from '@tauri-apps/plugin-http'
|
||||
import { ofetch } from 'ofetch'
|
||||
import { handleError } from '@/store/state.js'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
|
||||
export const useFetch = async (url, item, isSilent) => {
|
||||
try {
|
||||
const version = await getVersion()
|
||||
return await fetch(url, {
|
||||
method: 'GET',
|
||||
|
||||
return await ofetch(url, {
|
||||
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -36,8 +36,8 @@ export async function get_jre(path) {
|
||||
|
||||
// Tests JRE version by running 'java -version' on it.
|
||||
// Returns true if the version is valid, and matches given (after extraction)
|
||||
export async function test_jre(path, majorVersion) {
|
||||
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion })
|
||||
export async function test_jre(path, majorVersion, minorVersion) {
|
||||
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion, minorVersion })
|
||||
}
|
||||
|
||||
// Automatically installs specified java version
|
||||
|
||||
@@ -16,7 +16,3 @@ export async function logout() {
|
||||
export async function get() {
|
||||
return await invoke('plugin:mr-auth|get')
|
||||
}
|
||||
|
||||
export async function cancelLogin() {
|
||||
return await invoke('plugin:mr-auth|cancel_modrinth_login')
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,6 @@ export type AppSettings = {
|
||||
theme: ColorTheme
|
||||
default_page: 'home' | 'library'
|
||||
collapsed_navigation: boolean
|
||||
hide_nametag_skins_page: boolean
|
||||
advanced_rendering: boolean
|
||||
native_decorations: boolean
|
||||
toggle_sidebar: boolean
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -51,7 +51,6 @@ export type ServerStatus = {
|
||||
version?: {
|
||||
name: string
|
||||
protocol: number
|
||||
legacy: boolean
|
||||
}
|
||||
favicon?: string
|
||||
enforces_secure_chat: boolean
|
||||
@@ -71,17 +70,11 @@ export interface Chat {
|
||||
|
||||
export type ServerData = {
|
||||
refreshing: boolean
|
||||
lastSuccessfulRefresh?: number
|
||||
status?: ServerStatus
|
||||
rawMotd?: string | Chat
|
||||
renderedMotd?: string
|
||||
}
|
||||
|
||||
export type ProtocolVersion = {
|
||||
version: number
|
||||
legacy: boolean
|
||||
}
|
||||
|
||||
export async function get_recent_worlds(
|
||||
limit: number,
|
||||
displayStatuses?: DisplayStatus[],
|
||||
@@ -163,13 +156,13 @@ export async function remove_server_from_profile(path: string, index: number): P
|
||||
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
|
||||
}
|
||||
|
||||
export async function get_profile_protocol_version(path: string): Promise<ProtocolVersion | null> {
|
||||
export async function get_profile_protocol_version(path: string): Promise<number | null> {
|
||||
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
|
||||
}
|
||||
|
||||
export async function get_server_status(
|
||||
address: string,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
protocolVersion: number | null = null,
|
||||
): Promise<ServerStatus> {
|
||||
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
|
||||
}
|
||||
@@ -213,39 +206,30 @@ export function isServerWorld(world: World): world is ServerWorld {
|
||||
|
||||
export async function refreshServerData(
|
||||
serverData: ServerData,
|
||||
protocolVersion: ProtocolVersion | null,
|
||||
protocolVersion: number | null,
|
||||
address: string,
|
||||
): Promise<void> {
|
||||
const refreshTime = Date.now()
|
||||
serverData.refreshing = true
|
||||
await get_server_status(address, protocolVersion)
|
||||
.then((status) => {
|
||||
if (serverData.lastSuccessfulRefresh && serverData.lastSuccessfulRefresh > refreshTime) {
|
||||
// Don't update if there was a more recent successful refresh
|
||||
return
|
||||
}
|
||||
serverData.lastSuccessfulRefresh = Date.now()
|
||||
serverData.status = status
|
||||
if (status.description) {
|
||||
serverData.rawMotd = status.description
|
||||
serverData.renderedMotd = autoToHTML(status.description)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Refreshing addr: ${address}`, err)
|
||||
})
|
||||
.finally(() => {
|
||||
serverData.refreshing = false
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Refreshing addr ${address}`, protocolVersion, err)
|
||||
if (!protocolVersion?.legacy) {
|
||||
refreshServerData(serverData, { version: 74, legacy: true }, address)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function refreshServers(
|
||||
export async function refreshServers(
|
||||
worlds: World[],
|
||||
serverData: Record<string, ServerData>,
|
||||
protocolVersion: ProtocolVersion | null,
|
||||
protocolVersion: number | null,
|
||||
) {
|
||||
const servers = worlds.filter(isServerWorld)
|
||||
servers.forEach((server) => {
|
||||
@@ -259,8 +243,10 @@ export function refreshServers(
|
||||
})
|
||||
|
||||
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
|
||||
Object.keys(serverData).forEach((address) =>
|
||||
refreshServerData(serverData[address], protocolVersion, address),
|
||||
Promise.all(
|
||||
Object.keys(serverData).map((address) =>
|
||||
refreshServerData(serverData[address], protocolVersion, address),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -311,24 +297,15 @@ export async function refreshWorlds(instancePath: string): Promise<World[]> {
|
||||
return worlds ?? []
|
||||
}
|
||||
|
||||
export function hasServerQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
|
||||
if (!gameVersions.length) {
|
||||
return true
|
||||
}
|
||||
const FIRST_QUICK_PLAY_VERSION = '23w14a'
|
||||
|
||||
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
|
||||
const targetIndex = gameVersions.findIndex((v) => v.version === 'a1.0.5_01')
|
||||
|
||||
return versionIndex === -1 || targetIndex === -1 || versionIndex <= targetIndex
|
||||
}
|
||||
|
||||
export function hasWorldQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
|
||||
export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
|
||||
if (!gameVersions.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
|
||||
const targetIndex = gameVersions.findIndex((v) => v.version === '23w14a')
|
||||
const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION)
|
||||
|
||||
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
|
||||
}
|
||||
|
||||
@@ -377,17 +377,11 @@
|
||||
"instance.worlds.hardcore": {
|
||||
"message": "Hardcore mode"
|
||||
},
|
||||
"instance.worlds.incompatible_server": {
|
||||
"message": "Server is incompatible"
|
||||
"instance.worlds.no_quick_play": {
|
||||
"message": "You can only jump straight into worlds on Minecraft 1.20+"
|
||||
},
|
||||
"instance.worlds.no_contact": {
|
||||
"message": "Server couldn't be contacted"
|
||||
},
|
||||
"instance.worlds.no_server_quick_play": {
|
||||
"message": "You can only jump straight into servers on Minecraft Alpha 1.0.5+"
|
||||
},
|
||||
"instance.worlds.no_singleplayer_quick_play": {
|
||||
"message": "You can only jump straight into singleplayer worlds on Minecraft 1.20+"
|
||||
"instance.worlds.play_anyway": {
|
||||
"message": "Play anyway"
|
||||
},
|
||||
"instance.worlds.play_instance": {
|
||||
"message": "Play instance"
|
||||
|
||||
@@ -220,7 +220,6 @@ async function refreshSearch() {
|
||||
}
|
||||
}
|
||||
results.value = rawResults.result
|
||||
currentPage.value = 1
|
||||
|
||||
const persistentParams: LocationQuery = {}
|
||||
|
||||
@@ -266,7 +265,6 @@ async function onSearchChangeToTop() {
|
||||
|
||||
function clearSearch() {
|
||||
query.value = ''
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
@@ -10,7 +10,6 @@ import dayjs from 'dayjs'
|
||||
import { get_search_results } from '@/helpers/cache.js'
|
||||
import type { SearchResult } from '@modrinth/utils'
|
||||
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const route = useRoute()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
@@ -83,15 +82,13 @@ async function refreshFeaturedProjects() {
|
||||
await fetchInstances()
|
||||
await refreshFeaturedProjects()
|
||||
|
||||
const unlistenProfile = await profile_listener(
|
||||
async (e: { event: string; profile_path_id: string }) => {
|
||||
await fetchInstances()
|
||||
const unlistenProfile = await profile_listener(async (e) => {
|
||||
await fetchInstances()
|
||||
|
||||
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
||||
await refreshFeaturedProjects()
|
||||
}
|
||||
},
|
||||
)
|
||||
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
||||
await refreshFeaturedProjects()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProfile()
|
||||
@@ -100,8 +97,8 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="p-6 flex flex-col gap-2">
|
||||
<h1 v-if="recentInstances?.length > 0" class="m-0 text-2xl font-extrabold">Welcome back!</h1>
|
||||
<h1 v-else class="m-0 text-2xl font-extrabold">Welcome to Modrinth App!</h1>
|
||||
<h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
|
||||
<h1 v-else class="m-0 text-2xl">Welcome to Modrinth App!</h1>
|
||||
<RecentWorldsList :recent-instances="recentInstances" />
|
||||
<RowDisplay
|
||||
v-if="hasFeaturedProjects"
|
||||
|
||||
@@ -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>
|
||||
@@ -1,6 +1,5 @@
|
||||
import Index from './Index.vue'
|
||||
import Browse from './Browse.vue'
|
||||
import Worlds from './Worlds.vue'
|
||||
import Skins from './Skins.vue'
|
||||
|
||||
export { Index, Browse, Worlds, Skins }
|
||||
export { Index, Browse, Worlds }
|
||||
|
||||
@@ -483,7 +483,7 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: 100vh;
|
||||
height: calc(100vh - 11rem);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
|
||||
@@ -67,8 +67,7 @@
|
||||
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
||||
:world="world"
|
||||
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
||||
:supports-server-quick-play="supportsServerQuickPlay"
|
||||
:supports-world-quick-play="supportsWorldQuickPlay"
|
||||
:supports-quick-play="supportsQuickPlay"
|
||||
:current-protocol="protocolVersion"
|
||||
:playing-instance="playing"
|
||||
:playing-world="worldsMatch(world, worldPlaying)"
|
||||
@@ -135,7 +134,6 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
type ProtocolVersion,
|
||||
type SingleplayerWorld,
|
||||
type World,
|
||||
type ServerWorld,
|
||||
@@ -151,11 +149,10 @@ import {
|
||||
refreshWorld,
|
||||
sortWorlds,
|
||||
refreshServers,
|
||||
hasWorldQuickPlaySupport,
|
||||
hasQuickPlaySupport,
|
||||
refreshWorlds,
|
||||
handleDefaultProfileUpdateEvent,
|
||||
showWorldInFolder,
|
||||
hasServerQuickPlaySupport,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
|
||||
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
||||
@@ -213,9 +210,7 @@ const worldPlaying = ref<World>()
|
||||
const worlds = ref<World[]>([])
|
||||
const serverData = ref<Record<string, ServerData>>({})
|
||||
|
||||
const protocolVersion = ref<ProtocolVersion | null>(
|
||||
await get_profile_protocol_version(instance.value.path),
|
||||
)
|
||||
const protocolVersion = ref<number | null>(await get_profile_protocol_version(instance.value.path))
|
||||
|
||||
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
|
||||
if (e.profile_path_id !== instance.value.path) return
|
||||
@@ -251,7 +246,7 @@ async function refreshAllWorlds() {
|
||||
worlds.value = await refreshWorlds(instance.value.path).finally(
|
||||
() => (refreshingAll.value = false),
|
||||
)
|
||||
refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
||||
await refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
||||
|
||||
const hasNoWorlds = worlds.value.length === 0
|
||||
|
||||
@@ -357,11 +352,8 @@ function worldsMatch(world: World, other: World | undefined) {
|
||||
}
|
||||
|
||||
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
|
||||
const supportsServerQuickPlay = computed(() =>
|
||||
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||
)
|
||||
const supportsWorldQuickPlay = computed(() =>
|
||||
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||
const supportsQuickPlay = computed(() =>
|
||||
hasQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||
)
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
|
||||
@@ -34,14 +34,6 @@ export default new createRouter({
|
||||
breadcrumb: [{ name: 'Discover content' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/skins',
|
||||
name: 'Skins',
|
||||
component: Pages.Skins,
|
||||
meta: {
|
||||
breadcrumb: [{ name: 'Skins' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/library',
|
||||
name: 'Library',
|
||||
|
||||
@@ -29,8 +29,8 @@ export const useInstall = defineStore('installStore', {
|
||||
setIncompatibilityWarningModal(ref) {
|
||||
this.incompatibilityWarningModal = ref
|
||||
},
|
||||
showIncompatibilityWarningModal(instance, project, versions, selected, onInstall) {
|
||||
this.incompatibilityWarningModal.show(instance, project, versions, selected, onInstall)
|
||||
showIncompatibilityWarningModal(instance, project, versions, onInstall) {
|
||||
this.incompatibilityWarningModal.show(instance, project, versions, onInstall)
|
||||
},
|
||||
setModInstallModal(ref) {
|
||||
this.modInstallModal = ref
|
||||
@@ -133,13 +133,7 @@ export const install = async (
|
||||
callback(version.id)
|
||||
} else {
|
||||
const install = useInstall()
|
||||
install.showIncompatibilityWarningModal(
|
||||
instance,
|
||||
project,
|
||||
projectVersions,
|
||||
version,
|
||||
callback,
|
||||
)
|
||||
install.showIncompatibilityWarningModal(instance, project, projectVersions, callback)
|
||||
}
|
||||
} else {
|
||||
const versions = (await get_version_many(project.versions).catch(handleError)).sort(
|
||||
|
||||
@@ -41,7 +41,6 @@ export default {
|
||||
green: 'var(--color-green-highlight)',
|
||||
blue: 'var(--color-blue-highlight)',
|
||||
purple: 'var(--color-purple-highlight)',
|
||||
gray: 'var(--color-gray-highlight)',
|
||||
},
|
||||
divider: {
|
||||
DEFAULT: 'var(--color-divider)',
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
"strict": true
|
||||
},
|
||||
|
||||
@@ -4,8 +4,6 @@ import svgLoader from 'vite-svg-loader'
|
||||
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
import tauriConf from '../app/tauri.conf.json'
|
||||
|
||||
const projectRootDir = resolve(__dirname)
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
@@ -43,32 +41,17 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 1420,
|
||||
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
|
||||
// https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
build: {
|
||||
// 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
|
||||
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
|
||||
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: {
|
||||
esmExternals: true,
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
||||
println!("A browser window will now open, follow the login flow there.");
|
||||
let login = minecraft_auth::begin_login().await?;
|
||||
|
||||
println!("Open URL {} in a browser", login.auth_request_uri.as_str());
|
||||
println!("Open URL {} in a browser", login.redirect_uri.as_str());
|
||||
|
||||
println!("Please enter URL code: ");
|
||||
let mut input = String::new();
|
||||
@@ -27,10 +27,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
||||
|
||||
let credentials = minecraft_auth::finish_login(&input, login).await?;
|
||||
|
||||
println!(
|
||||
"Logged in user {}.",
|
||||
credentials.maybe_online_profile().await.name
|
||||
);
|
||||
println!("Logged in user {}.", credentials.username);
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
|
||||
4
apps/app/.gitignore
vendored
4
apps/app/.gitignore
vendored
@@ -1,2 +1,6 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by tauri, metadata generated at compile time
|
||||
/gen/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
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"
|
||||
license = "GPL-3.0-only"
|
||||
repository = "https://github.com/modrinth/code/apps/app/"
|
||||
@@ -17,22 +17,19 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_with.workspace = true
|
||||
|
||||
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-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"] }
|
||||
thiserror.workspace = true
|
||||
daedalus.workspace = true
|
||||
chrono.workspace = true
|
||||
either.workspace = true
|
||||
hyper = { workspace = true, features = ["server"] }
|
||||
hyper-util.workspace = true
|
||||
|
||||
url.workspace = true
|
||||
urlencoding.workspace = true
|
||||
|
||||
@@ -18,25 +18,5 @@
|
||||
<string>A Minecraft mod wants to access your camera.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
||||
@@ -99,33 +99,10 @@ fn main() {
|
||||
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(
|
||||
"mr-auth",
|
||||
InlinedPlugin::new()
|
||||
.commands(&[
|
||||
"modrinth_login",
|
||||
"logout",
|
||||
"get",
|
||||
"cancel_modrinth_login",
|
||||
])
|
||||
.commands(&["modrinth_login", "logout", "get"])
|
||||
.default_permission(
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
),
|
||||
@@ -174,6 +151,7 @@ fn main() {
|
||||
"profile_update_managed_modrinth_version",
|
||||
"profile_repair_managed_modrinth",
|
||||
"profile_run",
|
||||
"profile_run_credentials",
|
||||
"profile_kill",
|
||||
"profile_edit",
|
||||
"profile_edit_icon",
|
||||
|
||||
@@ -19,21 +19,12 @@
|
||||
"window-state:default",
|
||||
"window-state:allow-restore-state",
|
||||
"window-state:allow-save-window-state",
|
||||
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "https://modrinth.com/*" },
|
||||
{ "url": "https://*.modrinth.com/*" }
|
||||
]
|
||||
},
|
||||
|
||||
"auth:default",
|
||||
"import:default",
|
||||
"jre:default",
|
||||
"logs:default",
|
||||
"metadata:default",
|
||||
"minecraft-skins:default",
|
||||
"mr-auth:default",
|
||||
"profile-create:default",
|
||||
"pack:default",
|
||||
|
||||
@@ -33,7 +33,7 @@ pub async fn login<R: Runtime>(
|
||||
let window = tauri::WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"signin",
|
||||
tauri::WebviewUrl::External(flow.auth_request_uri.parse().map_err(
|
||||
tauri::WebviewUrl::External(flow.redirect_uri.parse().map_err(
|
||||
|_| {
|
||||
theseus::ErrorKind::OtherError(
|
||||
"Error parsing auth redirect URL".to_string(),
|
||||
@@ -77,7 +77,6 @@ pub async fn login<R: Runtime>(
|
||||
window.close()?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
|
||||
Ok(minecraft_auth::remove_user(user).await?)
|
||||
|
||||
@@ -41,8 +41,8 @@ pub async fn jre_find_filtered_jres(
|
||||
// Validates JRE at a given path
|
||||
// Returns None if the path is not a valid JRE
|
||||
#[tauri::command]
|
||||
pub async fn jre_get_jre(path: PathBuf) -> Result<JavaVersion> {
|
||||
Ok(jre::check_jre(path).await?)
|
||||
pub async fn jre_get_jre(path: PathBuf) -> Result<Option<JavaVersion>> {
|
||||
jre::check_jre(path).await.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
// Tests JRE of a certain version
|
||||
|
||||
@@ -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?)
|
||||
}
|
||||
@@ -7,7 +7,6 @@ pub mod import;
|
||||
pub mod jre;
|
||||
pub mod logs;
|
||||
pub mod metadata;
|
||||
pub mod minecraft_skins;
|
||||
pub mod mr_auth;
|
||||
pub mod pack;
|
||||
pub mod process;
|
||||
@@ -22,8 +21,6 @@ pub mod cache;
|
||||
pub mod friends;
|
||||
pub mod worlds;
|
||||
|
||||
mod oauth_utils;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
|
||||
|
||||
// // Main returnable Theseus GUI error
|
||||
|
||||
@@ -1,70 +1,79 @@
|
||||
use crate::api::Result;
|
||||
use crate::api::TheseusSerializableError;
|
||||
use crate::api::oauth_utils;
|
||||
use tauri::Manager;
|
||||
use tauri::Runtime;
|
||||
use chrono::{Duration, Utc};
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tauri::{Manager, Runtime, UserAttentionType};
|
||||
use theseus::prelude::*;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("mr-auth")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
modrinth_login,
|
||||
logout,
|
||||
get,
|
||||
cancel_modrinth_login,
|
||||
])
|
||||
.invoke_handler(tauri::generate_handler![modrinth_login, logout, get,])
|
||||
.build()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn modrinth_login<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
) -> Result<ModrinthCredentials> {
|
||||
let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel();
|
||||
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen(
|
||||
auth_code_recv_socket_tx,
|
||||
));
|
||||
) -> Result<Option<ModrinthCredentials>> {
|
||||
let redirect_uri = mr_auth::authenticate_begin_flow();
|
||||
|
||||
let auth_code_recv_socket = auth_code_recv_socket.await.unwrap()?;
|
||||
let start = Utc::now();
|
||||
|
||||
let auth_request_uri = format!(
|
||||
"{}?launcher=true&ipver={}&port={}",
|
||||
mr_auth::authenticate_begin_flow(),
|
||||
if auth_code_recv_socket.is_ipv4() {
|
||||
"4"
|
||||
} else {
|
||||
"6"
|
||||
},
|
||||
auth_code_recv_socket.port()
|
||||
);
|
||||
|
||||
app.opener()
|
||||
.open_url(auth_request_uri, None::<&str>)
|
||||
.map_err(|e| {
|
||||
TheseusSerializableError::Theseus(
|
||||
theseus::ErrorKind::OtherError(format!(
|
||||
"Failed to open auth request URI: {e}"
|
||||
))
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let Some(auth_code) = auth_code.await.unwrap()? else {
|
||||
return Err(TheseusSerializableError::Theseus(
|
||||
theseus::ErrorKind::OtherError("Login canceled".into()).into(),
|
||||
));
|
||||
};
|
||||
|
||||
let credentials = mr_auth::authenticate_finish_flow(&auth_code).await?;
|
||||
|
||||
if let Some(main_window) = app.get_window("main") {
|
||||
main_window.set_focus().ok();
|
||||
if let Some(window) = app.get_webview_window("modrinth-signin") {
|
||||
window.close()?;
|
||||
}
|
||||
|
||||
Ok(credentials)
|
||||
let window = tauri::WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"modrinth-signin",
|
||||
tauri::WebviewUrl::External(redirect_uri.parse().map_err(|_| {
|
||||
theseus::ErrorKind::OtherError(
|
||||
"Error parsing auth redirect URL".to_string(),
|
||||
)
|
||||
.as_error()
|
||||
})?),
|
||||
)
|
||||
.min_inner_size(420.0, 632.0)
|
||||
.inner_size(420.0, 632.0)
|
||||
.max_inner_size(420.0, 632.0)
|
||||
.zoom_hotkeys_enabled(false)
|
||||
.title("Sign into Modrinth")
|
||||
.always_on_top(true)
|
||||
.center()
|
||||
.build()?;
|
||||
|
||||
window.request_user_attention(Some(UserAttentionType::Critical))?;
|
||||
|
||||
while (Utc::now() - start) < Duration::minutes(10) {
|
||||
if window.title().is_err() {
|
||||
// user closed window, cancelling flow
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if window
|
||||
.url()?
|
||||
.as_str()
|
||||
.starts_with("https://launcher-files.modrinth.com")
|
||||
{
|
||||
let url = window.url()?;
|
||||
|
||||
let code = url.query_pairs().find(|(key, _)| key == "code");
|
||||
|
||||
window.close()?;
|
||||
|
||||
return if let Some((_, code)) = code {
|
||||
let val = mr_auth::authenticate_finish_flow(&code).await?;
|
||||
|
||||
Ok(Some(val))
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
window.close()?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -76,8 +85,3 @@ pub async fn logout() -> Result<()> {
|
||||
pub async fn get() -> Result<Option<ModrinthCredentials>> {
|
||||
Ok(theseus::mr_auth::get_credentials().await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cancel_modrinth_login() {
|
||||
oauth_utils::auth_code_reply::stop_listeners();
|
||||
}
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
//! A minimal OAuth 2.0 authorization code grant flow redirection/reply loopback URI HTTP
|
||||
//! server implementation, compliant with [RFC 6749]'s authorization code grant flow and
|
||||
//! [RFC 8252]'s best current practices for OAuth 2.0 in native apps.
|
||||
//!
|
||||
//! This server is needed for the step 4 of the OAuth authentication dance represented in
|
||||
//! figure 1 of [RFC 8252].
|
||||
//!
|
||||
//! Further reading: https://www.oauth.com/oauth2-servers/oauth-native-apps/redirect-urls-for-native-apps/
|
||||
//!
|
||||
//! [RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749
|
||||
//! [RFC 8252]: https://datatracker.ietf.org/doc/html/rfc8252
|
||||
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
sync::{LazyLock, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use hyper::body::Incoming;
|
||||
use hyper_util::rt::{TokioIo, TokioTimer};
|
||||
use theseus::ErrorKind;
|
||||
use tokio::{
|
||||
net::TcpListener,
|
||||
sync::{broadcast, oneshot},
|
||||
};
|
||||
|
||||
static SERVER_SHUTDOWN: LazyLock<broadcast::Sender<()>> =
|
||||
LazyLock::new(|| broadcast::channel(1024).0);
|
||||
|
||||
/// Starts a temporary HTTP server to receive OAuth 2.0 authorization code grant flow redirects
|
||||
/// on a loopback interface with an ephemeral port. The caller can know the bound socket address
|
||||
/// by listening on the counterpart channel for `listen_socket_tx`.
|
||||
///
|
||||
/// If the server is stopped before receiving an authorization code, `Ok(None)` is returned.
|
||||
pub async fn listen(
|
||||
listen_socket_tx: oneshot::Sender<Result<SocketAddr, theseus::Error>>,
|
||||
) -> Result<Option<String>, theseus::Error> {
|
||||
// IPv4 is tried first for the best compatibility and performance with most systems.
|
||||
// IPv6 is also tried in case IPv4 is not available. Resolving "localhost" is avoided
|
||||
// to prevent failures deriving from improper name resolution setup. Any available
|
||||
// ephemeral port is used to prevent conflicts with other services. This is all as per
|
||||
// RFC 8252's recommendations
|
||||
const ANY_LOOPBACK_SOCKET: &[SocketAddr] = &[
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
|
||||
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
|
||||
];
|
||||
|
||||
let listener = match TcpListener::bind(ANY_LOOPBACK_SOCKET).await {
|
||||
Ok(listener) => {
|
||||
listen_socket_tx
|
||||
.send(listener.local_addr().map_err(|e| {
|
||||
ErrorKind::OtherError(format!(
|
||||
"Failed to get auth code reply socket address: {e}"
|
||||
))
|
||||
.into()
|
||||
}))
|
||||
.ok();
|
||||
|
||||
listener
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg =
|
||||
format!("Failed to bind auth code reply socket: {e}");
|
||||
|
||||
listen_socket_tx
|
||||
.send(Err(ErrorKind::OtherError(error_msg.clone()).into()))
|
||||
.ok();
|
||||
|
||||
return Err(ErrorKind::OtherError(error_msg).into());
|
||||
}
|
||||
};
|
||||
|
||||
let mut auth_code = Mutex::new(None);
|
||||
let mut shutdown_notification = SERVER_SHUTDOWN.subscribe();
|
||||
|
||||
while auth_code.get_mut().unwrap().is_none() {
|
||||
let client_socket = tokio::select! {
|
||||
biased;
|
||||
_ = shutdown_notification.recv() => {
|
||||
break;
|
||||
}
|
||||
conn_accept_result = listener.accept() => {
|
||||
match conn_accept_result {
|
||||
Ok((socket, _)) => socket,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to accept auth code reply: {e}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = hyper::server::conn::http1::Builder::new()
|
||||
.keep_alive(false)
|
||||
.header_read_timeout(Duration::from_secs(5))
|
||||
.timer(TokioTimer::new())
|
||||
.auto_date_header(false)
|
||||
.serve_connection(
|
||||
TokioIo::new(client_socket),
|
||||
hyper::service::service_fn(|req| handle_reply(req, &auth_code)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to handle auth code reply: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(auth_code.into_inner().unwrap())
|
||||
}
|
||||
|
||||
/// Stops any active OAuth 2.0 authorization code grant flow reply listening HTTP servers.
|
||||
pub fn stop_listeners() {
|
||||
SERVER_SHUTDOWN.send(()).ok();
|
||||
}
|
||||
|
||||
async fn handle_reply(
|
||||
req: hyper::Request<Incoming>,
|
||||
auth_code_out: &Mutex<Option<String>>,
|
||||
) -> Result<hyper::Response<String>, hyper::http::Error> {
|
||||
if req.method() != hyper::Method::GET {
|
||||
return hyper::Response::builder()
|
||||
.status(hyper::StatusCode::METHOD_NOT_ALLOWED)
|
||||
.header("Allow", "GET")
|
||||
.body("".into());
|
||||
}
|
||||
|
||||
// The authorization code is guaranteed to be sent as a "code" query parameter
|
||||
// in the request URI query string as per RFC 6749 § 4.1.2
|
||||
let auth_code = req.uri().query().and_then(|query_string| {
|
||||
query_string
|
||||
.split('&')
|
||||
.filter_map(|query_pair| query_pair.split_once('='))
|
||||
.find_map(|(key, value)| (key == "code").then_some(value))
|
||||
});
|
||||
|
||||
let response = if let Some(auth_code) = auth_code {
|
||||
*auth_code_out.lock().unwrap() = Some(auth_code.to_string());
|
||||
|
||||
hyper::Response::builder()
|
||||
.status(hyper::StatusCode::OK)
|
||||
.header("Content-Type", "text/html;charset=utf-8")
|
||||
.body(
|
||||
include_str!("auth_code_reply/page.html")
|
||||
.replace("{{title}}", "Success")
|
||||
.replace("{{message}}", "You have successfully signed in! You can close this page now."),
|
||||
)
|
||||
} else {
|
||||
hyper::Response::builder()
|
||||
.status(hyper::StatusCode::BAD_REQUEST)
|
||||
.header("Content-Type", "text/html;charset=utf-8")
|
||||
.body(
|
||||
include_str!("auth_code_reply/page.html")
|
||||
.replace("{{title}}", "Error")
|
||||
.replace("{{message}}", "Authorization code not found. Please try signing in again."),
|
||||
)
|
||||
}?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
|
||||
//! Assorted utilities for OAuth 2.0 authorization flows.
|
||||
|
||||
pub mod auth_code_reply;
|
||||
@@ -28,6 +28,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
profile_update_managed_modrinth_version,
|
||||
profile_repair_managed_modrinth,
|
||||
profile_run,
|
||||
profile_run_credentials,
|
||||
profile_kill,
|
||||
profile_edit,
|
||||
profile_edit_icon,
|
||||
@@ -250,7 +251,23 @@ pub async fn profile_get_pack_export_candidates(
|
||||
// invoke('plugin:profile|profile_run', path)
|
||||
#[tauri::command]
|
||||
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
|
||||
let process = profile::run(path, QuickPlayType::None).await?;
|
||||
let process = profile::run(path, &QuickPlayType::None).await?;
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ use enumset::EnumSet;
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use theseus::prelude::ProcessMetadata;
|
||||
use theseus::profile::{QuickPlayType, get_full_path};
|
||||
use theseus::server_address::ServerAddress;
|
||||
use theseus::worlds::{
|
||||
DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
|
||||
WorldType, WorldWithProfile,
|
||||
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType,
|
||||
WorldWithProfile,
|
||||
};
|
||||
use theseus::{profile, worlds};
|
||||
|
||||
@@ -184,16 +183,14 @@ pub async fn remove_server_from_profile(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profile_protocol_version(
|
||||
path: &str,
|
||||
) -> Result<Option<ProtocolVersion>> {
|
||||
pub async fn get_profile_protocol_version(path: &str) -> Result<Option<i32>> {
|
||||
Ok(worlds::get_profile_protocol_version(path).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_server_status(
|
||||
address: &str,
|
||||
protocol_version: Option<ProtocolVersion>,
|
||||
protocol_version: Option<i32>,
|
||||
) -> Result<ServerStatus> {
|
||||
Ok(worlds::get_server_status(address, protocol_version).await?)
|
||||
}
|
||||
@@ -204,7 +201,7 @@ pub async fn start_join_singleplayer_world(
|
||||
world: String,
|
||||
) -> Result<ProcessMetadata> {
|
||||
let process =
|
||||
profile::run(path, QuickPlayType::Singleplayer(world)).await?;
|
||||
profile::run(path, &QuickPlayType::Singleplayer(world)).await?;
|
||||
|
||||
Ok(process)
|
||||
}
|
||||
@@ -214,11 +211,8 @@ pub async fn start_join_server(
|
||||
path: &str,
|
||||
address: &str,
|
||||
) -> Result<ProcessMetadata> {
|
||||
let process = profile::run(
|
||||
path,
|
||||
QuickPlayType::Server(ServerAddress::Unresolved(address.to_owned())),
|
||||
)
|
||||
.await?;
|
||||
let process =
|
||||
profile::run(path, &QuickPlayType::Server(address.to_owned())).await?;
|
||||
|
||||
Ok(process)
|
||||
}
|
||||
|
||||
@@ -183,7 +183,6 @@ fn main() {
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
@@ -249,7 +248,6 @@ fn main() {
|
||||
.plugin(api::logs::init())
|
||||
.plugin(api::jre::init())
|
||||
.plugin(api::metadata::init())
|
||||
.plugin(api::minecraft_skins::init())
|
||||
.plugin(api::pack::init())
|
||||
.plugin(api::process::init())
|
||||
.plugin(api::profile::init())
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
{
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": "v1Compatible",
|
||||
"windows": {
|
||||
"signCommand": {
|
||||
"cmd": "jsign",
|
||||
"args": [
|
||||
"sign",
|
||||
"--verbose",
|
||||
"--storetype",
|
||||
"DIGICERTONE",
|
||||
"--keystore",
|
||||
"https://clientauth.one.digicert.com",
|
||||
"--storepass",
|
||||
"env:DIGICERT_ONE_SIGNER_CREDENTIALS",
|
||||
"--tsaurl",
|
||||
"https://timestamp.sectigo.com,http://timestamp.digicert.com",
|
||||
"%1"
|
||||
]
|
||||
}
|
||||
}
|
||||
"createUpdaterArtifacts": "v1Compatible"
|
||||
},
|
||||
"build": {
|
||||
"features": ["updater"]
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"externalBin": [],
|
||||
"icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com",
|
||||
"nsis": {
|
||||
"installMode": "perMachine",
|
||||
"installerHooks": "./nsis/hooks.nsi"
|
||||
@@ -27,6 +30,7 @@
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "",
|
||||
"linux": {
|
||||
"deb": {
|
||||
@@ -41,7 +45,7 @@
|
||||
]
|
||||
},
|
||||
"productName": "Modrinth App",
|
||||
"version": "../app-frontend/package.json",
|
||||
"version": "0.9.5",
|
||||
"mainBinaryName": "Modrinth App",
|
||||
"identifier": "ModrinthApp",
|
||||
"plugins": {
|
||||
@@ -63,7 +67,6 @@
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"title": "Modrinth App",
|
||||
"label": "main",
|
||||
"width": 1280,
|
||||
"minHeight": 700,
|
||||
"minWidth": 1100,
|
||||
@@ -87,9 +90,9 @@
|
||||
"capabilities": ["ads", "core", "plugins"],
|
||||
"csp": {
|
||||
"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/"],
|
||||
"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'",
|
||||
"script-src": "https://*.posthog.com 'self'",
|
||||
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'",
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM rust:1.88.0 AS build
|
||||
FROM rust:1.87.0 AS build
|
||||
ENV PKG_CONFIG_ALLOW_CROSS=1
|
||||
|
||||
WORKDIR /usr/src/daedalus
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/usr/src/daedalus/target \
|
||||
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||
--mount=type=cache,target=/usr/local/cargo/registry \
|
||||
cargo build --release --package daedalus_client
|
||||
RUN cargo build --release --package daedalus_client
|
||||
|
||||
FROM build AS artifacts
|
||||
|
||||
RUN --mount=type=cache,target=/usr/src/daedalus/target \
|
||||
mkdir /daedalus \
|
||||
&& cp /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=artifacts /daedalus /daedalus
|
||||
RUN update-ca-certificates
|
||||
|
||||
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
||||
WORKDIR /daedalus_client
|
||||
CMD ["/daedalus/daedalus_client"]
|
||||
|
||||
CMD /daedalus/daedalus_client
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
Support: https://support.modrinth.com
|
||||
Status page: https://status.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
|
||||
Modrinth source code: https://github.com/modrinth
|
||||
Help translate Modrinth: https://crowdin.com/project/modrinth
|
||||
|
||||
@@ -3,37 +3,57 @@ title: Labrinth (API)
|
||||
description: Guide for contributing to Modrinth's backend
|
||||
---
|
||||
|
||||
This project is part of our [monorepo](https://github.com/modrinth/code). You can find it in the `apps/labrinth` directory. The instructions below assume that you have switched your working directory to the `apps/labrinth` subdirectory.
|
||||
This project is part of our [monorepo](https://github.com/modrinth/code). You can find it in the `apps/labrinth` directory.
|
||||
|
||||
[Labrinth] is the Rust-based backend serving Modrinth's API with the help of the [Actix](https://actix.rs) framework. To get started with a labrinth instance, install docker, docker-compose (which comes with Docker), and [Rust]. The initial startup can be done simply with the command `docker-compose up`, or with `docker compose up` (Compose V2 and later). That will deploy a PostgreSQL database on port 5432 and a MeiliSearch instance on port 7700. To run the API itself, you'll need to use the `cargo run` command, this will deploy the API on port 8000.
|
||||
[labrinth] is the Rust-based backend serving Modrinth's API with the help of the [Actix](https://actix.rs) framework. To get started with a labrinth instance, install docker, docker-compose (which comes with Docker), and [Rust]. The initial startup can be done simply with the command `docker-compose up`, or with `docker compose up` (Compose V2 and later). That will deploy a PostgreSQL database on port 5432 and a MeiliSearch instance on port 7700. To run the API itself, you'll need to use the `cargo run` command, this will deploy the API on port 8000.
|
||||
|
||||
To get a basic configuration, copy the `.env.local` file to `.env`. Now, you'll have to install the sqlx CLI, which can be done with cargo:
|
||||
|
||||
```sh
|
||||
cargo install sqlx-cli --no-default-features --features mysql,sqlite,postgres,rustls,completions
|
||||
```bash
|
||||
cargo install --git https://github.com/launchbadge/sqlx sqlx-cli --no-default-features --features postgres,rustls
|
||||
```
|
||||
|
||||
From there, you can create the database and set up its schema with one simple command:
|
||||
From there, you can create the database and perform all database migrations with one simple command:
|
||||
|
||||
```sh
|
||||
cargo sqlx database setup
|
||||
```bash
|
||||
sqlx database setup
|
||||
```
|
||||
|
||||
To enable Labrinth to create projects and serve useful metadata to the frontend build scripts, you'll need to seed the database with several key entities:
|
||||
Finally, if on Linux, you will need the OpenSSL library. On Debian-based systems, this involves the `pkg-config` and `libssl-dev` packages.
|
||||
|
||||
1. Categories, in the `categories` table.
|
||||
2. Loaders and their fields, in the `loaders`, `loader_fields`, `loader_field_enums`, `loader_field_enum_values`, and `loader_fields_loaders` tables.
|
||||
3. Project types and their allowed loaders and games, in the `project_types`, `loaders_project_types`, and `loaders_project_types_games` tables.
|
||||
4. Optionally, to moderate projects from the frontend, an admin user, in the `users` table.
|
||||
To enable labrinth to create a project, you need to add two things.
|
||||
|
||||
The most convenient way to do this seeding is with the [psql](https://www.postgresql.org/docs/current/app-psql.html) command line tool and the pre-existing seed data fixture. This fixture was generated by dumping the official staging environment database at a specific point in time, and defines an admin user with email `admin@modrinth.invalid` and password `admin`:
|
||||
1. An entry in the `loaders` table.
|
||||
2. An entry in the `loaders_project_types` table.
|
||||
|
||||
```sh
|
||||
source .env
|
||||
psql "$DATABASE_URL" < fixtures/labrinth-seed-data-202508052143.sql
|
||||
A minimal setup can be done from the command line with [psql](https://www.postgresql.org/docs/current/app-psql.html):
|
||||
|
||||
```bash
|
||||
psql --host=localhost --port=5432 -U <username, default is labrinth> -W
|
||||
```
|
||||
|
||||
You can find more example SQL statements for seeding the database in the `tests/files/dummy_data.sql` file.
|
||||
The default password for the database is `labrinth`. Once you've connected, run
|
||||
|
||||
```sql
|
||||
INSERT INTO loaders VALUES (0, 'placeholder_loader');
|
||||
INSERT INTO loaders_project_types VALUES (0, 1); -- modloader id, supported type id
|
||||
INSERT INTO categories VALUES (0, 'placeholder_category', 1); -- category id, category, project type id
|
||||
```
|
||||
|
||||
This will initialize your database with a modloader called 'placeholder_loader', with id 0, and marked as supporting mods only. It will also create a category called 'placeholder_category' that is marked as supporting mods only
|
||||
If you would like 'placeholder_loader' to be marked as supporting modpacks too, run
|
||||
|
||||
```sql
|
||||
INSERT INTO loaders_project_types VALUES (0, 2); -- modloader id, supported type id
|
||||
```
|
||||
|
||||
If you would like 'placeholder_category' to be marked as supporting modpacks too, run
|
||||
|
||||
```sql
|
||||
INSERT INTO categories VALUES (0, 'placeholder_category', 2); -- modloader id, supported type id
|
||||
```
|
||||
|
||||
You can find more example SQL statements for seeding the database in the `apps/labrinth/tests/files/dummy_data.sql` file.
|
||||
|
||||
The majority of configuration is done at runtime using [dotenvy](https://crates.io/crates/dotenvy) and the `.env` file. Each of the variables and what they do can be found in the dropdown below. Additionally, there are three command line options that can be used to specify to MeiliSearch what you want to do.
|
||||
|
||||
@@ -91,13 +111,14 @@ The OAuth configuration options are fairly self-explanatory. For help setting up
|
||||
|
||||
If you're prepared to contribute by submitting a pull request, ensure you have met the following criteria:
|
||||
|
||||
- `cargo fmt --all` has been run.
|
||||
- `cargo clippy --all-targets` has been run.
|
||||
- `cargo fmt` has been run.
|
||||
- `cargo clippy` has been run.
|
||||
- `cargo check` has been run.
|
||||
- `cargo sqlx prepare` has been run.
|
||||
|
||||
> Note: If you encounter issues with `sqlx` saying 'no queries found' after running `cargo sqlx prepare`, you may need to ensure the installed version of `sqlx-cli` matches the current version of `sqlx` used [in labrinth](https://github.com/modrinth/labrinth/blob/master/Cargo.toml).
|
||||
|
||||
[Discord]: https://discord.modrinth.com
|
||||
[GitHub]: https://github.com/modrinth
|
||||
[Labrinth]: https://github.com/modrinth/code/tree/main/apps/labrinth
|
||||
[labrinth]: https://github.com/modrinth/labrinth
|
||||
[Rust]: https://www.rust-lang.org/tools/install
|
||||
|
||||
@@ -38,12 +38,9 @@
|
||||
"@intercom/messenger-js-sdk": "^0.0.14",
|
||||
"@ltd/j-toml": "^1.38.0",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/blog": "workspace:*",
|
||||
"@modrinth/moderation": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"ace-builds": "^1.36.2",
|
||||
@@ -59,12 +56,10 @@
|
||||
"markdown-it": "14.1.0",
|
||||
"pathe": "^1.1.2",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-plugin-persistedstate": "^4.4.1",
|
||||
"prettier": "^3.6.2",
|
||||
"qrcode.vue": "^3.4.0",
|
||||
"semver": "^7.5.4",
|
||||
"three": "^0.172.0",
|
||||
"vue-confetti-explosion": "^1.0.2",
|
||||
"@types/three": "^0.172.0",
|
||||
"vue-multiselect": "3.0.0-alpha.2",
|
||||
"vue-typed-virtual-list": "^1.0.10",
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
|
||||
@@ -197,13 +197,13 @@
|
||||
}
|
||||
|
||||
> :where(
|
||||
input + *,
|
||||
.input-group + *,
|
||||
.textarea-wrapper + *,
|
||||
.chips + *,
|
||||
.resizable-textarea-wrapper + *,
|
||||
.input-div + *
|
||||
) {
|
||||
input + *,
|
||||
.input-group + *,
|
||||
.textarea-wrapper + *,
|
||||
.chips + *,
|
||||
.resizable-textarea-wrapper + *,
|
||||
.input-div + *
|
||||
) {
|
||||
&:not(:empty) {
|
||||
margin-block-start: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
@@ -115,12 +115,10 @@ html {
|
||||
--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:
|
||||
0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
|
||||
--shadow-raised: 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
|
||||
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);
|
||||
--shadow-floating:
|
||||
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||
--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.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;
|
||||
@@ -152,8 +150,8 @@ html {
|
||||
rgba(255, 255, 255, 0.35) 0%,
|
||||
rgba(255, 255, 255, 0.2695) 100%
|
||||
);
|
||||
--landing-blob-shadow:
|
||||
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(255, 255, 255, 0.45);
|
||||
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16),
|
||||
inset 2px 2px 64px rgba(255, 255, 255, 0.45);
|
||||
|
||||
--landing-card-bg: rgba(255, 255, 255, 0.8);
|
||||
--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: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
|
||||
--shadow-floating:
|
||||
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||
--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.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;
|
||||
|
||||
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp");
|
||||
--landing-maze-gradient-bg:
|
||||
linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
|
||||
--landing-maze-gradient-bg: linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
|
||||
url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
|
||||
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
|
||||
|
||||
@@ -288,8 +284,7 @@ html {
|
||||
rgba(44, 48, 79, 0.35) 0%,
|
||||
rgba(32, 35, 50, 0.2695) 100%
|
||||
);
|
||||
--landing-blob-shadow:
|
||||
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
|
||||
--landing-blob-shadow: 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-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
||||
@@ -365,9 +360,8 @@ body {
|
||||
// Defaults
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
--font-standard:
|
||||
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
|
||||
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
|
||||
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
|
||||
font-family: var(--font-standard);
|
||||
font-size: 16px;
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
<template>
|
||||
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
|
||||
<nuxt-link
|
||||
to="/servers"
|
||||
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="light-image hidden 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]"
|
||||
/>
|
||||
</nuxt-link>
|
||||
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
|
||||
<p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
|
||||
<nuxt-link to="/plus" class="mt-auto items-center gap-1 text-purple hover:underline">
|
||||
<span>
|
||||
Support creators and Modrinth ad-free with
|
||||
<span class="font-bold">Modrinth+</span>
|
||||
</span>
|
||||
<ChevronRightIcon class="relative top-[3px] h-5 w-5" />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
|
||||
>
|
||||
@@ -23,6 +18,8 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ChevronRightIcon } from "@modrinth/assets";
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
// {
|
||||
@@ -140,16 +137,3 @@ iframe[id^="google_ads_iframe"] {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.light,
|
||||
.light-mode {
|
||||
.dark-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.light-image {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
1134
apps/frontend/src/components/ui/ModerationChecklist.vue
Normal file
1134
apps/frontend/src/components/ui/ModerationChecklist.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,55 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { MailIcon, CheckIcon } from "@modrinth/assets";
|
||||
import { ref } from "vue";
|
||||
import { useBaseFetch } from "~/composables/fetch.js";
|
||||
|
||||
const auth = await useAuth();
|
||||
const showSubscriptionConfirmation = ref(false);
|
||||
const showSubscribeButton = useAsyncData(
|
||||
async () => {
|
||||
if (auth.value?.user) {
|
||||
try {
|
||||
const { subscribed } = await useBaseFetch("auth/email/subscribe", {
|
||||
method: "GET",
|
||||
});
|
||||
return !subscribed;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ watch: [auth], server: false },
|
||||
);
|
||||
|
||||
async function subscribe() {
|
||||
try {
|
||||
await useBaseFetch("auth/email/subscribe", {
|
||||
method: "POST",
|
||||
});
|
||||
showSubscriptionConfirmation.value = true;
|
||||
} catch {
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
showSubscriptionConfirmation.value = false;
|
||||
showSubscribeButton.status.value = "success";
|
||||
showSubscribeButton.data.value = false;
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonStyled
|
||||
v-if="showSubscribeButton.status.value === 'success' && showSubscribeButton.data.value"
|
||||
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>
|
||||
@@ -1,10 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="vue-notification-group experimental-styles-within"
|
||||
:class="{
|
||||
'intercom-present': isIntercomPresent,
|
||||
rightwards: moveNotificationsRight,
|
||||
}"
|
||||
:class="{ 'intercom-present': isIntercomPresent }"
|
||||
>
|
||||
<transition-group name="notifs">
|
||||
<div
|
||||
@@ -85,7 +82,6 @@ import {
|
||||
CopyIcon,
|
||||
} from "@modrinth/assets";
|
||||
const notifications = useNotifications();
|
||||
const { isVisible: moveNotificationsRight } = useNotificationRightwards();
|
||||
|
||||
const isIntercomPresent = ref(false);
|
||||
|
||||
@@ -164,15 +160,6 @@ function copyToClipboard(notif) {
|
||||
bottom: 5rem;
|
||||
}
|
||||
|
||||
&.rightwards {
|
||||
right: unset !important;
|
||||
left: 1.5rem;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
left: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.vue-notification-wrapper {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -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>
|
||||
@@ -1,179 +0,0 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Avatar :src="report.project.icon_url" size="3rem" class="flex-shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-lg font-semibold">{{ report.project.title }}</h3>
|
||||
|
||||
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
|
||||
<nuxt-link
|
||||
v-if="report.target"
|
||||
:to="`/${report.target.type}/${report.target.slug}`"
|
||||
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
|
||||
>
|
||||
<Avatar
|
||||
:src="report.target.avatar_url"
|
||||
:circle="report.target.type === 'user'"
|
||||
size="1rem"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<span class="truncate">
|
||||
<OrganizationIcon
|
||||
v-if="report.target.type === 'organization'"
|
||||
class="align-middle"
|
||||
/>
|
||||
{{ report.target.name }}
|
||||
</span>
|
||||
</nuxt-link>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
|
||||
>
|
||||
Score: {{ report.priority_score }}
|
||||
</span>
|
||||
<span
|
||||
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold"
|
||||
:class="{
|
||||
'text-brand': report.status === 'approved',
|
||||
'text-red': report.status === 'rejected',
|
||||
'text-secondary': report.status === 'pending',
|
||||
}"
|
||||
>
|
||||
{{ report.status.charAt(0).toUpperCase() + report.status.slice(1) }}
|
||||
</span>
|
||||
<span class="max-w-[200px] truncate font-mono text-xs sm:max-w-none">
|
||||
{{
|
||||
report.version.files.find((file) => file.primary)?.filename ||
|
||||
"Unknown primary file"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-2 flex flex-col items-stretch gap-2 sm:mt-0 sm:flex-row sm:items-center sm:gap-2"
|
||||
>
|
||||
<span class="hidden whitespace-nowrap text-sm text-secondary sm:block">
|
||||
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
|
||||
</span>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled class="flex-1 sm:flex-none">
|
||||
<button
|
||||
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
|
||||
:disabled="!isPending"
|
||||
class="w-full sm:w-auto"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled class="flex-1 sm:flex-none">
|
||||
<button
|
||||
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
|
||||
:disabled="!isPending"
|
||||
class="w-full sm:w-auto"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center gap-2 sm:justify-start">
|
||||
<ButtonStyled circular>
|
||||
<nuxt-link :to="versionUrl">
|
||||
<EyeIcon />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<OverflowMenu :options="quickActions">
|
||||
<template #default>
|
||||
<EllipsisVerticalIcon />
|
||||
</template>
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon />
|
||||
<span class="hidden sm:inline">Copy ID</span>
|
||||
</template>
|
||||
<template #copy-link>
|
||||
<LinkIcon />
|
||||
<span class="hidden sm:inline">Copy link</span>
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-secondary sm:hidden">
|
||||
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
Avatar,
|
||||
useRelativeTime,
|
||||
OverflowMenu,
|
||||
type OverflowMenuOption,
|
||||
ButtonStyled,
|
||||
} from "@modrinth/ui";
|
||||
import {
|
||||
EllipsisVerticalIcon,
|
||||
OrganizationIcon,
|
||||
EyeIcon,
|
||||
ClipboardCopyIcon,
|
||||
LinkIcon,
|
||||
} from "@modrinth/assets";
|
||||
import type { ExtendedDelphiReport } from "@modrinth/moderation";
|
||||
|
||||
const props = defineProps<{
|
||||
report: ExtendedDelphiReport;
|
||||
}>();
|
||||
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
const isPending = computed(() => props.report.status === "pending");
|
||||
|
||||
const quickActions: OverflowMenuOption[] = [
|
||||
{
|
||||
id: "copy-link",
|
||||
action: () => {
|
||||
const base = window.location.origin;
|
||||
const reviewUrl = `${base}/moderation/tech-reviews?q=${props.report.version.id}`;
|
||||
navigator.clipboard.writeText(reviewUrl).then(() => {
|
||||
addNotification({
|
||||
type: "success",
|
||||
title: "Tech review link copied",
|
||||
text: "The link to this tech review has been copied to your clipboard.",
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "copy-id",
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(props.report.version.id).then(() => {
|
||||
addNotification({
|
||||
type: "success",
|
||||
title: "Version ID copied",
|
||||
text: "The ID of this version has been copied to your clipboard.",
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const versionUrl = computed(() => {
|
||||
return `/${props.report.project.project_type}/${props.report.project.slug}/versions/${props.report.version.id}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -1,204 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="universal-card flex min-h-[6rem] flex-col justify-between gap-3 rounded-lg p-4 sm:h-24 sm:flex-row sm:items-center sm:gap-0"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div class="flex-shrink-0 rounded-lg">
|
||||
<Avatar size="48px" :src="queueEntry.project.icon_url" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<h3 class="truncate text-lg font-semibold">
|
||||
{{ queueEntry.project.name }}
|
||||
</h3>
|
||||
<nuxt-link
|
||||
v-if="queueEntry.owner"
|
||||
target="_blank"
|
||||
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
|
||||
:to="`/user/${queueEntry.owner.user.username}`"
|
||||
>
|
||||
<Avatar
|
||||
:src="queueEntry.owner.user.avatar_url"
|
||||
circle
|
||||
size="16px"
|
||||
class="inline-block flex-shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ queueEntry.owner.user.username }}</span>
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
v-else-if="queueEntry.org"
|
||||
target="_blank"
|
||||
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
|
||||
:to="`/organization/${queueEntry.org.slug}`"
|
||||
>
|
||||
<Avatar
|
||||
:src="queueEntry.org.icon_url"
|
||||
circle
|
||||
size="16px"
|
||||
class="inline-block flex-shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ queueEntry.org.name }}</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-1">
|
||||
<span class="flex items-center gap-1 whitespace-nowrap text-sm">
|
||||
<BoxIcon
|
||||
v-if="queueEntry.project.project_type === 'mod'"
|
||||
class="size-4 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<PaintbrushIcon
|
||||
v-else-if="queueEntry.project.project_type === 'resourcepack'"
|
||||
class="size-4 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<BracesIcon
|
||||
v-else-if="queueEntry.project.project_type === 'datapack'"
|
||||
class="size-4 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<PackageOpenIcon
|
||||
v-else-if="queueEntry.project.project_type === 'modpack'"
|
||||
class="size-4 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<GlassesIcon
|
||||
v-else-if="queueEntry.project.project_type === 'shader'"
|
||||
class="size-4 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<PlugIcon
|
||||
v-else-if="queueEntry.project.project_type === 'plugin'"
|
||||
class="size-4 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="hidden sm:inline">{{
|
||||
props.queueEntry.project.project_types.map(formatProjectType).join(", ")
|
||||
}}</span>
|
||||
<span class="sm:hidden">{{
|
||||
formatProjectType(props.queueEntry.project.project_type ?? "project").substring(0, 3)
|
||||
}}</span>
|
||||
</span>
|
||||
|
||||
<span class="hidden text-sm sm:inline">•</span>
|
||||
|
||||
<div class="flex flex-row gap-2 text-sm">
|
||||
Requesting
|
||||
<Badge
|
||||
v-if="props.queueEntry.project.requested_status"
|
||||
:type="props.queueEntry.project.requested_status"
|
||||
class="status"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="hidden text-sm sm:inline">•</span>
|
||||
|
||||
<span
|
||||
v-tooltip="`Since ${queuedDate.toLocaleString()}`"
|
||||
class="truncate text-sm"
|
||||
:class="{
|
||||
'text-red': daysInQueue > 4,
|
||||
'text-orange': daysInQueue > 2,
|
||||
}"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ getSubmittedTime(queueEntry) }}</span>
|
||||
<span class="sm:hidden">{{
|
||||
getSubmittedTime(queueEntry).replace("Submitted ", "")
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 sm:justify-start">
|
||||
<ButtonStyled circular>
|
||||
<NuxtLink target="_blank" :to="`/project/${queueEntry.project.slug}`">
|
||||
<EyeIcon class="size-4" />
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular color="orange" @click="openProjectForReview">
|
||||
<button>
|
||||
<ScaleIcon class="size-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
EyeIcon,
|
||||
PaintbrushIcon,
|
||||
ScaleIcon,
|
||||
BoxIcon,
|
||||
GlassesIcon,
|
||||
PlugIcon,
|
||||
PackageOpenIcon,
|
||||
BracesIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { useRelativeTime, Avatar, ButtonStyled, Badge } from "@modrinth/ui";
|
||||
import {
|
||||
formatProjectType,
|
||||
type Organization,
|
||||
type Project,
|
||||
type TeamMember,
|
||||
} from "@modrinth/utils";
|
||||
import { computed } from "vue";
|
||||
import { useModerationStore } from "~/store/moderation.ts";
|
||||
import type { ModerationProject } from "~/helpers/moderation";
|
||||
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
const moderationStore = useModerationStore();
|
||||
|
||||
const props = defineProps<{
|
||||
queueEntry: ModerationProject;
|
||||
}>();
|
||||
|
||||
function getDaysQueued(date: Date): number {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
const queuedDate = computed(() => {
|
||||
return dayjs(
|
||||
props.queueEntry.project.queued ||
|
||||
props.queueEntry.project.created ||
|
||||
props.queueEntry.project.updated,
|
||||
);
|
||||
});
|
||||
|
||||
const daysInQueue = computed(() => {
|
||||
return getDaysQueued(queuedDate.value.toDate());
|
||||
});
|
||||
|
||||
function openProjectForReview() {
|
||||
moderationStore.setSingleProject(props.queueEntry.project.id);
|
||||
navigateTo({
|
||||
name: "type-id",
|
||||
params: {
|
||||
type: "project",
|
||||
id: props.queueEntry.project.id,
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getSubmittedTime(project: any): string {
|
||||
const date =
|
||||
props.queueEntry.project.queued ||
|
||||
props.queueEntry.project.created ||
|
||||
props.queueEntry.project.updated;
|
||||
if (!date) return "Unknown";
|
||||
|
||||
try {
|
||||
return `Submitted ${formatRelativeTime(dayjs(date).toISOString())}`;
|
||||
} catch {
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,275 +0,0 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<div
|
||||
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
|
||||
>
|
||||
<span class="text-md flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<span class="flex items-center gap-2">
|
||||
Reported for
|
||||
<span class="whitespace-nowrap rounded-full align-middle font-semibold text-contrast">
|
||||
{{ formattedReportType }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="hidden sm:inline">By</span>
|
||||
<span class="sm:hidden">Reporter:</span>
|
||||
<nuxt-link
|
||||
:to="`/user/${report.reporter_user.username}`"
|
||||
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
|
||||
>
|
||||
<Avatar
|
||||
:src="report.reporter_user.avatar_url"
|
||||
circle
|
||||
size="1.75rem"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ report.reporter_user.username }}</span>
|
||||
</nuxt-link>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div class="flex flex-row items-center gap-2 self-end sm:self-auto">
|
||||
<span class="text-md whitespace-nowrap text-secondary">{{
|
||||
formatRelativeTime(report.created)
|
||||
}}</span>
|
||||
<ButtonStyled v-if="visibleQuickReplies.length > 0" circular>
|
||||
<OverflowMenu :options="visibleQuickReplies">
|
||||
<span class="hidden sm:inline">Quick Reply</span>
|
||||
<span class="sr-only sm:hidden">Quick Reply</span>
|
||||
<ChevronDownIcon />
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<OverflowMenu :options="quickActions">
|
||||
<template #default>
|
||||
<EllipsisVerticalIcon />
|
||||
</template>
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon />
|
||||
<span class="hidden sm:inline">Copy ID</span>
|
||||
</template>
|
||||
<template #copy-link>
|
||||
<LinkIcon />
|
||||
<span class="hidden sm:inline">Copy link</span>
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4 rounded-xl border-solid text-divider" />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Avatar
|
||||
:src="reportItemAvatarUrl"
|
||||
:circle="report.item_type === 'user'"
|
||||
size="3rem"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block truncate text-lg font-semibold">{{ reportItemTitle }}</span>
|
||||
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
|
||||
<nuxt-link
|
||||
v-if="report.target && report.item_type != 'user'"
|
||||
:to="`/${report.target.type}/${report.target.slug}`"
|
||||
class="inline-flex flex-row items-center gap-1 truncate transition-colors duration-100 ease-in-out hover:text-brand"
|
||||
>
|
||||
<Avatar
|
||||
:src="report.target?.avatar_url"
|
||||
:circle="report.target.type === 'user'"
|
||||
size="1rem"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<span class="truncate">
|
||||
<OrganizationIcon
|
||||
v-if="report.target.type === 'organization'"
|
||||
class="align-middle"
|
||||
/>
|
||||
{{ report.target.name || "Unknown User" }}
|
||||
</span>
|
||||
</nuxt-link>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
|
||||
>
|
||||
{{ formattedItemType }}
|
||||
</span>
|
||||
<span
|
||||
v-if="report.item_type === 'version' && report.version"
|
||||
class="max-w-[200px] truncate font-mono text-xs sm:max-w-none"
|
||||
>
|
||||
{{
|
||||
report.version.files.find((file) => file.primary)?.filename || "Unknown Version"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end sm:justify-start">
|
||||
<ButtonStyled circular>
|
||||
<nuxt-link :to="reportItemUrl">
|
||||
<EyeIcon />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleRegion class="my-4" ref="collapsibleRegion">
|
||||
<ReportThread
|
||||
v-if="report.thread"
|
||||
ref="reportThread"
|
||||
class="mb-16 sm:mb-0"
|
||||
:thread="report.thread"
|
||||
:report="report"
|
||||
:reporter="report.reporter_user"
|
||||
@update-thread="updateThread"
|
||||
/>
|
||||
</CollapsibleRegion>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
useRelativeTime,
|
||||
OverflowMenu,
|
||||
type OverflowMenuOption,
|
||||
CollapsibleRegion,
|
||||
ButtonStyled,
|
||||
} from "@modrinth/ui";
|
||||
import {
|
||||
EllipsisVerticalIcon,
|
||||
OrganizationIcon,
|
||||
EyeIcon,
|
||||
ClipboardCopyIcon,
|
||||
LinkIcon,
|
||||
} from "@modrinth/assets";
|
||||
import {
|
||||
type ExtendedReport,
|
||||
reportQuickReplies,
|
||||
type ReportQuickReply,
|
||||
} from "@modrinth/moderation";
|
||||
import ChevronDownIcon from "../servers/icons/ChevronDownIcon.vue";
|
||||
import ReportThread from "../thread/ReportThread.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
report: ExtendedReport;
|
||||
}>();
|
||||
|
||||
const reportThread = ref<InstanceType<typeof ReportThread> | null>(null);
|
||||
const collapsibleRegion = ref<InstanceType<typeof CollapsibleRegion> | null>(null);
|
||||
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
|
||||
function updateThread(newThread: any) {
|
||||
if (props.report.thread) {
|
||||
Object.assign(props.report.thread, newThread);
|
||||
}
|
||||
}
|
||||
|
||||
const quickActions: OverflowMenuOption[] = [
|
||||
{
|
||||
id: "copy-link",
|
||||
action: () => {
|
||||
const base = window.location.origin;
|
||||
const reportUrl = `${base}/moderation/reports/${props.report.id}`;
|
||||
navigator.clipboard.writeText(reportUrl).then(() => {
|
||||
addNotification({
|
||||
type: "success",
|
||||
title: "Report link copied",
|
||||
text: "The link to this report has been copied to your clipboard.",
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "copy-id",
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(props.report.id).then(() => {
|
||||
addNotification({
|
||||
type: "success",
|
||||
title: "Report ID copied",
|
||||
text: "The ID of this report has been copied to your clipboard.",
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
|
||||
return reportQuickReplies
|
||||
.filter((reply) => {
|
||||
if (reply.shouldShow === undefined) return true;
|
||||
if (typeof reply.shouldShow === "function") {
|
||||
return reply.shouldShow(props.report);
|
||||
}
|
||||
|
||||
return reply.shouldShow;
|
||||
})
|
||||
.map(
|
||||
(reply) =>
|
||||
({
|
||||
id: reply.label,
|
||||
action: () => handleQuickReply(reply),
|
||||
}) as OverflowMenuOption,
|
||||
);
|
||||
});
|
||||
|
||||
async function handleQuickReply(reply: ReportQuickReply) {
|
||||
const message =
|
||||
typeof reply.message === "function" ? await reply.message(props.report) : reply.message;
|
||||
|
||||
collapsibleRegion.value?.setCollapsed(false);
|
||||
await nextTick();
|
||||
reportThread.value?.setReplyContent(message);
|
||||
}
|
||||
|
||||
const reportItemAvatarUrl = computed(() => {
|
||||
switch (props.report.item_type) {
|
||||
case "project":
|
||||
case "version":
|
||||
return props.report.project?.icon_url || "";
|
||||
case "user":
|
||||
return props.report.user?.avatar_url || "";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const reportItemTitle = computed(() => {
|
||||
if (props.report.item_type === "user") return props.report.user?.username || "Unknown User";
|
||||
|
||||
return props.report.project?.title || "Unknown Project";
|
||||
});
|
||||
|
||||
const reportItemUrl = computed(() => {
|
||||
switch (props.report.item_type) {
|
||||
case "user":
|
||||
return `/user/${props.report.user?.username}`;
|
||||
case "project":
|
||||
return `/${props.report.project?.project_type}/${props.report.project?.slug}`;
|
||||
case "version":
|
||||
return `/${props.report.project?.project_type}/${props.report.project?.slug}/versions/${props.report.version?.id}`;
|
||||
}
|
||||
});
|
||||
|
||||
const formattedItemType = computed(() => {
|
||||
const itemType = props.report.item_type;
|
||||
return itemType.charAt(0).toUpperCase() + itemType.slice(1);
|
||||
});
|
||||
|
||||
const formattedReportType = computed(() => {
|
||||
const reportType = props.report.report_type;
|
||||
|
||||
// some are split by -, some are split by " "
|
||||
const words = reportType.includes("-") ? reportType.split("-") : reportType.split(" ");
|
||||
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -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 } 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().includes("MAC");
|
||||
}
|
||||
|
||||
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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,513 +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 already obtained.</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, useSessionStorage } 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 = useSessionStorage<ModerationModpackItem[] | null>(
|
||||
`modpack-permissions-data-${props.projectId}`,
|
||||
null,
|
||||
{
|
||||
serializer: {
|
||||
read: (v: any) => (v ? JSON.parse(v) : null),
|
||||
write: (v: any) => JSON.stringify(v),
|
||||
},
|
||||
},
|
||||
);
|
||||
const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
|
||||
`modpack-permissions-permanent-no-${props.projectId}`,
|
||||
[],
|
||||
{
|
||||
serializer: {
|
||||
read: (v: any) => (v ? JSON.parse(v) : []),
|
||||
write: (v: any) => JSON.stringify(v),
|
||||
},
|
||||
},
|
||||
);
|
||||
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 permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {})
|
||||
.filter(([_, file]) => file.status === "permanent-no")
|
||||
.map(
|
||||
([sha1, file]): ModerationModpackItem => ({
|
||||
sha1,
|
||||
file_name: file.file_name,
|
||||
type: "identified",
|
||||
status: file.status,
|
||||
approved: null,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
||||
|
||||
permanentNoFiles.value = permanentNoItems;
|
||||
|
||||
const sortedData: ModerationModpackItem[] = [
|
||||
...Object.entries(data.identified || {})
|
||||
.filter(
|
||||
([_, file]) =>
|
||||
file.status !== "yes" &&
|
||||
file.status !== "with-attribution-and-source" &&
|
||||
file.status !== "permanent-no",
|
||||
)
|
||||
.map(
|
||||
([sha1, file]): ModerationModpackItem => ({
|
||||
sha1,
|
||||
file_name: file.file_name,
|
||||
type: "identified",
|
||||
status: file.status,
|
||||
approved: null,
|
||||
...(file.status === "unidentified" && {
|
||||
proof: "",
|
||||
url: "",
|
||||
title: "",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
|
||||
...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 = [];
|
||||
permanentNoFiles.value = [];
|
||||
persistAll();
|
||||
}
|
||||
}
|
||||
|
||||
function goToPrevious(): void {
|
||||
if (currentIndex.value > 0) {
|
||||
currentIndex.value--;
|
||||
persistAll();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
modPackData,
|
||||
(newValue) => {
|
||||
persistedModPackData.value = newValue;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
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(
|
||||
modPackData,
|
||||
(newValue) => {
|
||||
if (newValue && newValue.length === 0) {
|
||||
emit("complete");
|
||||
clearPersistedData();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.projectId,
|
||||
() => {
|
||||
clearPersistedData();
|
||||
loadPersistedData();
|
||||
if (!modPackData.value) {
|
||||
fetchModPackData();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function getModpackFiles(): {
|
||||
interactive: ModerationModpackItem[];
|
||||
permanentNo: ModerationModpackItem[];
|
||||
} {
|
||||
return {
|
||||
interactive: modPackData.value || [],
|
||||
permanentNo: permanentNoFiles.value,
|
||||
};
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getModpackFiles,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.modpack-buttons {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user