Compare commits
2 Commits
revert-397
...
josiah/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee9d4fad33 | ||
|
|
140b4060e6 |
@@ -1 +0,0 @@
|
|||||||
.gitignore
|
|
||||||
34
.gitattributes
vendored
34
.gitattributes
vendored
@@ -1,35 +1 @@
|
|||||||
* text=auto eol=lf
|
* text=auto eol=lf
|
||||||
|
|
||||||
# SQLx calculates a checksum of migration scripts at build time to compare
|
|
||||||
# it with the checksum of the applied migration for the same version at
|
|
||||||
# runtime, to know if the migration script has been changed, and thus the
|
|
||||||
# DB schema went out of sync with the code.
|
|
||||||
#
|
|
||||||
# However, such checksum treats the script as a raw byte stream, taking
|
|
||||||
# into account inconsequential differences like different line endings
|
|
||||||
# in different OSes. When combined with Git's EOL conversion and mixed
|
|
||||||
# native and cross-compilation scenarios, this leads to existing
|
|
||||||
# migrations that didn't change having potentially different checksums
|
|
||||||
# according to the environment they were built in, which can break the
|
|
||||||
# migration system when deploying the Modrinth App, rendering it
|
|
||||||
# unusable.
|
|
||||||
#
|
|
||||||
# The gitattribute above ensures that all text files are checked out
|
|
||||||
# with LF line endings, but widely deployed app versions were built
|
|
||||||
# without this attribute set, which left such line endings variable to
|
|
||||||
# the platform. Thus, there is no perfect solution to this problem:
|
|
||||||
# forcing CRLF here would break Linux and macOS users, forcing LF
|
|
||||||
# breaks Windows users, and leaving it unspecified may still lead to
|
|
||||||
# line ending differences when cross-compiling from Linux to Windows
|
|
||||||
# or vice versa, or having Git configured with different line
|
|
||||||
# conversion settings. Moreover, there is no `eol=native` attribute,
|
|
||||||
# and using CI-only scripts to convert line endings would make the
|
|
||||||
# builds differ between CI and most local environments. So, let's pick
|
|
||||||
# the least bad option: let Git handle line endings using its
|
|
||||||
# configuration by leaving it unspecified, which works fine as long as
|
|
||||||
# people don't mess with Git's line ending settings, which is the vast
|
|
||||||
# majority of cases.
|
|
||||||
/packages/app-lib/migrations/20240711194701_init.sql !eol
|
|
||||||
/packages/app-lib/migrations/20240813205023_drop-active-unique.sql !eol
|
|
||||||
/packages/app-lib/migrations/20240930001852_disable-personalized-ads.sql !eol
|
|
||||||
/packages/app-lib/migrations/20241222013857_feature-flags.sql !eol
|
|
||||||
|
|||||||
5
.github/workflows/labrinth-docker.yml
vendored
5
.github/workflows/labrinth-docker.yml
vendored
@@ -18,6 +18,9 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
docker:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./apps/labrinth
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
@@ -35,6 +38,8 @@ jobs:
|
|||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: docker_build
|
id: docker_build
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
|
env:
|
||||||
|
SQLX_OFFLINE: true
|
||||||
with:
|
with:
|
||||||
file: ./apps/labrinth/Dockerfile
|
file: ./apps/labrinth/Dockerfile
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
|||||||
150
.github/workflows/theseus-build.yml
vendored
150
.github/workflows/theseus-build.yml
vendored
@@ -1,150 +0,0 @@
|
|||||||
name: Modrinth App build
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
paths:
|
|
||||||
- .github/workflows/theseus-build.yml
|
|
||||||
- 'apps/app/**'
|
|
||||||
- 'apps/app-frontend/**'
|
|
||||||
- 'packages/app-lib/**'
|
|
||||||
- 'packages/app-macros/**'
|
|
||||||
- 'packages/assets/**'
|
|
||||||
- 'packages/ui/**'
|
|
||||||
- 'packages/utils/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
sign-windows-binaries:
|
|
||||||
description: Sign Windows binaries
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
required: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
platform: [macos-latest, windows-latest, ubuntu-22.04]
|
|
||||||
include:
|
|
||||||
- platform: macos-latest
|
|
||||||
artifact-target-name: universal-apple-darwin
|
|
||||||
- platform: windows-latest
|
|
||||||
artifact-target-name: x86_64-pc-windows-msvc
|
|
||||||
- platform: ubuntu-22.04
|
|
||||||
artifact-target-name: x86_64-unknown-linux-gnu
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 📥 Check out code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: 🧰 Setup Rust toolchain
|
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
|
||||||
with:
|
|
||||||
rustflags: ''
|
|
||||||
target: ${{ startsWith(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
|
|
||||||
|
|
||||||
- name: 🧰 Install pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
|
|
||||||
- name: 🧰 Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version-file: .nvmrc
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: 🧰 Install Linux build dependencies
|
|
||||||
if: startsWith(matrix.platform, 'ubuntu')
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
|
||||||
|
|
||||||
- name: 🧰 Setup Dasel
|
|
||||||
uses: jaxxstorm/action-install-gh-release@v2.1.0
|
|
||||||
with:
|
|
||||||
repo: TomWright/dasel
|
|
||||||
tag: v2.8.1
|
|
||||||
extension-matching: disable
|
|
||||||
rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
|
|
||||||
chmod: 0755
|
|
||||||
|
|
||||||
- name: ⚙️ Set application version
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
|
|
||||||
echo "Setting application version to $APP_VERSION"
|
|
||||||
dasel put -f apps/app/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
|
|
||||||
dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
|
|
||||||
dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version'
|
|
||||||
|
|
||||||
- name: 💨 Setup Turbo cache
|
|
||||||
uses: rharkor/caching-for-turbo@v1.8
|
|
||||||
|
|
||||||
- name: 🧰 Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: ✍️ Set up Windows code signing
|
|
||||||
if: startsWith(matrix.platform, 'windows')
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
if [ '${{ startsWith(github.ref, 'refs/tags/v') || inputs.sign-windows-binaries }}' = 'true' ]; then
|
|
||||||
choco install jsign --ignore-dependencies # GitHub runners come with a global Java installation already
|
|
||||||
else
|
|
||||||
dasel delete -f apps/app/tauri-release.conf.json 'bundle.windows.signCommand'
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: 🔨 Build macOS app
|
|
||||||
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
|
|
||||||
if: startsWith(matrix.platform, 'macos')
|
|
||||||
env:
|
|
||||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
|
||||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
|
||||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
||||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: 🔨 Build Linux app
|
|
||||||
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
|
|
||||||
if: startsWith(matrix.platform, 'ubuntu')
|
|
||||||
env:
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: 🔨 Build Windows app
|
|
||||||
run: |
|
|
||||||
[System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream
|
|
||||||
$env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD"
|
|
||||||
$env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
|
|
||||||
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater'
|
|
||||||
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
|
|
||||||
if: startsWith(matrix.platform, 'windows')
|
|
||||||
env:
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
|
||||||
DIGICERT_ONE_SIGNER_API_KEY: ${{ secrets.DIGICERT_ONE_SIGNER_API_KEY }}
|
|
||||||
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64 }}
|
|
||||||
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD }}
|
|
||||||
|
|
||||||
- name: 📤 Upload app bundles
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: App bundle (${{ matrix.artifact-target-name }})
|
|
||||||
path: |
|
|
||||||
target/release/bundle/appimage/Modrinth App_*.AppImage*
|
|
||||||
target/release/bundle/deb/Modrinth App_*.deb*
|
|
||||||
target/release/bundle/rpm/Modrinth App-*.rpm*
|
|
||||||
target/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz*
|
|
||||||
target/universal-apple-darwin/release/bundle/dmg/Modrinth App_*.dmg*
|
|
||||||
target/release/bundle/nsis/Modrinth App_*-setup.exe*
|
|
||||||
target/release/bundle/nsis/Modrinth App_*-setup.nsis.zip*
|
|
||||||
272
.github/workflows/theseus-release.yml
vendored
272
.github/workflows/theseus-release.yml
vendored
@@ -1,118 +1,186 @@
|
|||||||
name: Modrinth App release
|
name: 'Modrinth App build'
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
paths:
|
||||||
|
- .github/workflows/theseus-release.yml
|
||||||
|
- 'apps/app/**'
|
||||||
|
- 'apps/app-frontend/**'
|
||||||
|
- 'packages/app-lib/**'
|
||||||
|
- 'packages/app-macros/**'
|
||||||
|
- 'packages/assets/**'
|
||||||
|
- 'packages/ui/**'
|
||||||
|
- 'packages/utils/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version-tag:
|
sign-windows-binaries:
|
||||||
description: Version tag to release to the wide public
|
description: Sign Windows binaries
|
||||||
type: string
|
type: boolean
|
||||||
required: true
|
default: true
|
||||||
release-notes:
|
required: false
|
||||||
description: Release notes to include in the Tauri version manifest
|
|
||||||
default: A new release of the Modrinth App is available!
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
build:
|
||||||
name: Release Modrinth App
|
strategy:
|
||||||
runs-on: ubuntu-latest
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
platform: [macos-latest, windows-latest, ubuntu-22.04]
|
||||||
|
|
||||||
env:
|
runs-on: ${{ matrix.platform }}
|
||||||
LINUX_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-unknown-linux-gnu)
|
|
||||||
WINDOWS_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-pc-windows-msvc)
|
|
||||||
MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME: App bundle (universal-apple-darwin)
|
|
||||||
LAUNCHER_FILES_BUCKET_BASE_URL: https://launcher-files.modrinth.com
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Download Modrinth App artifacts
|
- uses: actions/checkout@v4
|
||||||
uses: dawidd6/action-download-artifact@v11
|
|
||||||
|
- name: Rust setup (mac)
|
||||||
|
if: startsWith(matrix.platform, 'macos')
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
with:
|
with:
|
||||||
workflow: theseus-build.yml
|
rustflags: ''
|
||||||
workflow_conclusion: success
|
target: x86_64-apple-darwin
|
||||||
event: push
|
|
||||||
branch: ${{ inputs.version-tag }}
|
|
||||||
use_unzip: true
|
|
||||||
|
|
||||||
- name: 🛠️ Generate version manifest
|
- name: Rust setup
|
||||||
env:
|
if: "!startsWith(matrix.platform, 'macos')"
|
||||||
VERSION_TAG: ${{ inputs.version-tag }}
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
RELEASE_NOTES: ${{ inputs.release-notes }}
|
with:
|
||||||
|
rustflags: ''
|
||||||
|
|
||||||
|
- name: Setup rust cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
target/**
|
||||||
|
!target/*/release/bundle/*/*.dmg
|
||||||
|
!target/*/release/bundle/*/*.app.tar.gz
|
||||||
|
!target/*/release/bundle/*/*.app.tar.gz.sig
|
||||||
|
!target/release/bundle/*/*.dmg
|
||||||
|
!target/release/bundle/*/*.app.tar.gz
|
||||||
|
!target/release/bundle/*/*.app.tar.gz.sig
|
||||||
|
|
||||||
|
!target/release/bundle/appimage/*.AppImage
|
||||||
|
!target/release/bundle/appimage/*.AppImage.tar.gz
|
||||||
|
!target/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||||
|
!target/release/bundle/deb/*.deb
|
||||||
|
!target/release/bundle/rpm/*.rpm
|
||||||
|
|
||||||
|
!target/release/bundle/msi/*.msi
|
||||||
|
!target/release/bundle/msi/*.msi.zip
|
||||||
|
!target/release/bundle/msi/*.msi.zip.sig
|
||||||
|
|
||||||
|
!target/release/bundle/nsis/*.exe
|
||||||
|
!target/release/bundle/nsis/*.nsis.zip
|
||||||
|
!target/release/bundle/nsis/*.nsis.zip.sig
|
||||||
|
key: ${{ runner.os }}-rust-target-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-rust-target-
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
|
||||||
|
- name: Install pnpm via corepack
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
# Reference: https://tauri.app/plugin/updater/#server-support
|
corepack enable
|
||||||
jq -nc \
|
corepack prepare --activate
|
||||||
--arg versionTag "${VERSION_TAG#v}" \
|
|
||||||
--arg releaseNotes "$RELEASE_NOTES" \
|
|
||||||
--rawfile macOsAarch64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \
|
|
||||||
--rawfile macOsX64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \
|
|
||||||
--rawfile linuxX64UpdateArtifactSignature "${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/appimage/Modrinth App_${VERSION_TAG#v}_amd64.AppImage.tar.gz.sig" \
|
|
||||||
--rawfile windowsX64UpdateArtifactSignature "${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/nsis/Modrinth App_${VERSION_TAG#v}_x64-setup.nsis.zip.sig" \
|
|
||||||
'{
|
|
||||||
"version": $versionTag,
|
|
||||||
"notes": $releaseNotes,
|
|
||||||
"pub_date": now | todateiso8601,
|
|
||||||
"platforms": {
|
|
||||||
"darwin-aarch64": {
|
|
||||||
"signature": $macOsAarch64UpdateArtifactSignature,
|
|
||||||
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App.app.tar.gz")",
|
|
||||||
"install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App_" + $versionTag + "_universal.dmg")"]
|
|
||||||
},
|
|
||||||
"darwin-x86_64": {
|
|
||||||
"signature": $macOsX64UpdateArtifactSignature,
|
|
||||||
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App.app.tar.gz")",
|
|
||||||
"install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App_" + $versionTag + "_universal.dmg")"]
|
|
||||||
},
|
|
||||||
"linux-x86_64": {
|
|
||||||
"signature": $linuxX64UpdateArtifactSignature,
|
|
||||||
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.AppImage.tar.gz")",
|
|
||||||
"install_urls": [
|
|
||||||
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.deb")",
|
|
||||||
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.AppImage")",
|
|
||||||
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App-" + $versionTag + "-1.x86_64.rpm")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"windows-x86_64": {
|
|
||||||
"signature": $windowsX64UpdateArtifactSignature,
|
|
||||||
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/windows/\("Modrinth App_" + $versionTag + "_x64-setup.nsis.zip")",
|
|
||||||
"install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/windows/\("Modrinth App_" + $versionTag + "_x64-setup.exe")"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}' > updates.json
|
|
||||||
|
|
||||||
echo "Generated manifest for version ${VERSION_TAG}:"
|
- name: Get pnpm store directory
|
||||||
cat updates.json
|
id: pnpm-cache
|
||||||
|
shell: bash
|
||||||
- name: 📤 Upload release artifacts
|
|
||||||
env:
|
|
||||||
VERSION_TAG: ${{ inputs.version-tag }}
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.LAUNCHER_FILES_BUCKET_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.LAUNCHER_FILES_BUCKET_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_BUCKET: ${{ secrets.LAUNCHER_FILES_BUCKET_NAME }}
|
|
||||||
AWS_REGION: ${{ secrets.LAUNCHER_FILES_BUCKET_REGION }}
|
|
||||||
AWS_ENDPOINT_URL: ${{ secrets.LAUNCHER_FILES_BUCKET_ENDPOINT_URL }}
|
|
||||||
AWS_PAGER: ''
|
|
||||||
# Work around incompatible checksum behavior with some S3-like object storage providers,
|
|
||||||
# such as Cloudflare R2. See:
|
|
||||||
# - https://developers.cloudflare.com/r2/examples/aws/aws-cli/
|
|
||||||
# - https://developers.cloudflare.com/r2/examples/aws/aws-sdk-java/
|
|
||||||
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
|
|
||||||
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
|
|
||||||
run: |
|
run: |
|
||||||
for macosBundleType in 'macos' 'dmg'; do
|
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||||
aws s3 cp --recursive \
|
|
||||||
"${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/${macosBundleType}" \
|
|
||||||
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/macos"
|
|
||||||
done
|
|
||||||
|
|
||||||
for linuxBundleType in 'appimage' 'deb' 'rpm'; do
|
- name: Setup pnpm cache
|
||||||
aws s3 cp --recursive \
|
uses: actions/cache@v4
|
||||||
"${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${linuxBundleType}" \
|
with:
|
||||||
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/linux"
|
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||||
done
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
|
||||||
for windowsBundleType in 'nsis'; do
|
- name: install dependencies (ubuntu only)
|
||||||
aws s3 cp --recursive \
|
if: startsWith(matrix.platform, 'ubuntu')
|
||||||
"${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${windowsBundleType}" \
|
run: |
|
||||||
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/windows"
|
sudo apt-get update
|
||||||
done
|
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev pkg-config libayatana-appindicator3-dev librsvg2-dev
|
||||||
|
|
||||||
aws s3 cp updates.json "s3://${AWS_BUCKET}"
|
- name: Install code signing client (Windows only)
|
||||||
|
if: startsWith(matrix.platform, 'windows')
|
||||||
|
run: choco install jsign --ignore-dependencies # GitHub runners come with a global Java installation already
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Disable Windows code signing for non-final release builds
|
||||||
|
if: ${{ startsWith(matrix.platform, 'windows') && !startsWith(github.ref, 'refs/tags/v') && !inputs.sign-windows-binaries }}
|
||||||
|
run: |
|
||||||
|
jq 'del(.bundle.windows.signCommand)' apps/app/tauri-release.conf.json > apps/app/tauri-release.conf.json.new
|
||||||
|
Move-Item -Path apps/app/tauri-release.conf.json.new -Destination apps/app/tauri-release.conf.json -Force
|
||||||
|
|
||||||
|
- name: build app (macos)
|
||||||
|
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
|
||||||
|
if: startsWith(matrix.platform, 'macos')
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: build app (Linux)
|
||||||
|
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
|
||||||
|
if: startsWith(matrix.platform, 'ubuntu')
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: build app (Windows)
|
||||||
|
run: |
|
||||||
|
[System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream
|
||||||
|
$env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD"
|
||||||
|
$env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
|
||||||
|
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater'
|
||||||
|
Remove-Item -Path signer-client-cert.p12
|
||||||
|
if: startsWith(matrix.platform, 'windows')
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
|
DIGICERT_ONE_SIGNER_API_KEY: ${{ secrets.DIGICERT_ONE_SIGNER_API_KEY }}
|
||||||
|
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64 }}
|
||||||
|
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD }}
|
||||||
|
|
||||||
|
- name: upload ${{ matrix.platform }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.platform }}
|
||||||
|
path: |
|
||||||
|
target/*/release/bundle/*/*.dmg
|
||||||
|
target/*/release/bundle/*/*.app.tar.gz
|
||||||
|
target/*/release/bundle/*/*.app.tar.gz.sig
|
||||||
|
target/release/bundle/*/*.dmg
|
||||||
|
target/release/bundle/*/*.app.tar.gz
|
||||||
|
target/release/bundle/*/*.app.tar.gz.sig
|
||||||
|
|
||||||
|
target/release/bundle/*/*.AppImage
|
||||||
|
target/release/bundle/*/*.AppImage.tar.gz
|
||||||
|
target/release/bundle/*/*.AppImage.tar.gz.sig
|
||||||
|
target/release/bundle/*/*.deb
|
||||||
|
target/release/bundle/*/*.rpm
|
||||||
|
|
||||||
|
target/release/bundle/msi/*.msi
|
||||||
|
target/release/bundle/msi/*.msi.zip
|
||||||
|
target/release/bundle/msi/*.msi.zip.sig
|
||||||
|
|
||||||
|
target/release/bundle/nsis/*.exe
|
||||||
|
target/release/bundle/nsis/*.nsis.zip
|
||||||
|
target/release/bundle/nsis/*.nsis.zip.sig
|
||||||
|
|||||||
5
.github/workflows/turbo-ci.yml
vendored
5
.github/workflows/turbo-ci.yml
vendored
@@ -76,8 +76,3 @@ jobs:
|
|||||||
|
|
||||||
- name: 🔍 Lint and test
|
- name: 🔍 Lint and test
|
||||||
run: pnpm run ci
|
run: pnpm run ci
|
||||||
|
|
||||||
- name: 🔍 Verify intl:extract has been run
|
|
||||||
run: |
|
|
||||||
pnpm intl:extract
|
|
||||||
git diff --exit-code --color */*/src/locales/en-US/index.json
|
|
||||||
|
|||||||
726
Cargo.lock
generated
726
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
47
Cargo.toml
47
Cargo.toml
@@ -25,7 +25,7 @@ actix-ws = "0.3.0"
|
|||||||
argon2 = { version = "0.5.3", features = ["std"] }
|
argon2 = { version = "0.5.3", features = ["std"] }
|
||||||
ariadne = { path = "packages/ariadne" }
|
ariadne = { path = "packages/ariadne" }
|
||||||
async_zip = "0.0.17"
|
async_zip = "0.0.17"
|
||||||
async-compression = { version = "0.4.25", default-features = false }
|
async-compression = { version = "0.4.24", default-features = false }
|
||||||
async-recursion = "1.1.1"
|
async-recursion = "1.1.1"
|
||||||
async-stripe = { version = "0.41.0", default-features = false, features = [
|
async-stripe = { version = "0.41.0", default-features = false, features = [
|
||||||
"runtime-tokio-hyper-rustls",
|
"runtime-tokio-hyper-rustls",
|
||||||
@@ -37,7 +37,6 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [
|
|||||||
async-walkdir = "2.1.0"
|
async-walkdir = "2.1.0"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
bitflags = "2.9.1"
|
bitflags = "2.9.1"
|
||||||
bytemuck = "1.23.0"
|
|
||||||
bytes = "1.10.1"
|
bytes = "1.10.1"
|
||||||
censor = "0.3.0"
|
censor = "0.3.0"
|
||||||
chardetng = "0.1.17"
|
chardetng = "0.1.17"
|
||||||
@@ -48,7 +47,6 @@ color-thief = "0.2.2"
|
|||||||
console-subscriber = "0.4.1"
|
console-subscriber = "0.4.1"
|
||||||
daedalus = { path = "packages/daedalus" }
|
daedalus = { path = "packages/daedalus" }
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.1.0"
|
||||||
data-url = "0.3.1"
|
|
||||||
deadpool-redis = "0.21.1"
|
deadpool-redis = "0.21.1"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
discord-rich-presence = "0.2.5"
|
discord-rich-presence = "0.2.5"
|
||||||
@@ -63,16 +61,10 @@ fs4 = { version = "0.13.1", default-features = false }
|
|||||||
futures = { version = "0.3.31", default-features = false }
|
futures = { version = "0.3.31", default-features = false }
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
hashlink = "0.10.0"
|
hashlink = "0.10.0"
|
||||||
heck = "0.5.0"
|
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
hickory-resolver = "0.25.2"
|
hickory-resolver = "0.25.2"
|
||||||
hmac = "0.12.1"
|
hmac = "0.12.1"
|
||||||
hyper-rustls = { version = "0.27.7", default-features = false, features = [
|
hyper-tls = "0.6.0"
|
||||||
"http1",
|
|
||||||
"native-tokio",
|
|
||||||
"ring",
|
|
||||||
"tls12",
|
|
||||||
] }
|
|
||||||
hyper-util = "0.1.14"
|
hyper-util = "0.1.14"
|
||||||
iana-time-zone = "0.1.63"
|
iana-time-zone = "0.1.63"
|
||||||
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
|
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
|
||||||
@@ -98,7 +90,6 @@ notify = { version = "8.0.0", default-features = false }
|
|||||||
notify-debouncer-mini = { version = "0.6.0", default-features = false }
|
notify-debouncer-mini = { version = "0.6.0", default-features = false }
|
||||||
p256 = "0.13.2"
|
p256 = "0.13.2"
|
||||||
paste = "1.0.15"
|
paste = "1.0.15"
|
||||||
png = "0.17.16"
|
|
||||||
prometheus = "0.14.0"
|
prometheus = "0.14.0"
|
||||||
quartz_nbt = "0.2.9"
|
quartz_nbt = "0.2.9"
|
||||||
quick-xml = "0.37.5"
|
quick-xml = "0.37.5"
|
||||||
@@ -106,9 +97,8 @@ rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
|
|||||||
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
|
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
|
||||||
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
|
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
reqwest = { version = "0.12.20", default-features = false }
|
reqwest = { version = "0.12.19", default-features = false }
|
||||||
rgb = "0.8.50"
|
rust_decimal = { version = "1.37.1", features = [
|
||||||
rust_decimal = { version = "1.37.2", features = [
|
|
||||||
"serde-with-float",
|
"serde-with-float",
|
||||||
"serde-with-str",
|
"serde-with-str",
|
||||||
] }
|
] }
|
||||||
@@ -119,7 +109,7 @@ rust-s3 = { version = "0.35.1", default-features = false, features = [
|
|||||||
"tokio-rustls-tls",
|
"tokio-rustls-tls",
|
||||||
] }
|
] }
|
||||||
rusty-money = "0.4.1"
|
rusty-money = "0.4.1"
|
||||||
sentry = { version = "0.41.0", default-features = false, features = [
|
sentry = { version = "0.38.1", default-features = false, features = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"contexts",
|
"contexts",
|
||||||
"debug-images",
|
"debug-images",
|
||||||
@@ -127,13 +117,13 @@ sentry = { version = "0.41.0", default-features = false, features = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"rustls",
|
"rustls",
|
||||||
] }
|
] }
|
||||||
sentry-actix = "0.41.0"
|
sentry-actix = "0.38.1"
|
||||||
serde = "1.0.219"
|
serde = "1.0.219"
|
||||||
serde_bytes = "0.11.17"
|
serde_bytes = "0.11.17"
|
||||||
serde_cbor = "0.11.2"
|
serde_cbor = "0.11.2"
|
||||||
serde_ini = "0.2.0"
|
serde_ini = "0.2.0"
|
||||||
serde_json = "1.0.140"
|
serde_json = "1.0.140"
|
||||||
serde_with = "3.13.0"
|
serde_with = "3.12.0"
|
||||||
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
|
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
|
||||||
sha1 = "0.10.6"
|
sha1 = "0.10.6"
|
||||||
sha1_smol = { version = "1.0.1", features = ["std"] }
|
sha1_smol = { version = "1.0.1", features = ["std"] }
|
||||||
@@ -142,19 +132,18 @@ spdx = "0.10.8"
|
|||||||
sqlx = { version = "0.8.6", default-features = false }
|
sqlx = { version = "0.8.6", default-features = false }
|
||||||
sysinfo = { version = "0.35.2", default-features = false }
|
sysinfo = { version = "0.35.2", default-features = false }
|
||||||
tar = "0.4.44"
|
tar = "0.4.44"
|
||||||
tauri = "2.6.1"
|
tauri = "2.5.1"
|
||||||
tauri-build = "2.3.0"
|
tauri-build = "2.2.0"
|
||||||
tauri-plugin-deep-link = "2.4.0"
|
tauri-plugin-deep-link = "2.3.0"
|
||||||
tauri-plugin-dialog = "2.3.0"
|
tauri-plugin-dialog = "2.2.2"
|
||||||
tauri-plugin-http = "2.5.0"
|
tauri-plugin-opener = "2.2.7"
|
||||||
tauri-plugin-opener = "2.4.0"
|
tauri-plugin-os = "2.2.1"
|
||||||
tauri-plugin-os = "2.3.0"
|
tauri-plugin-single-instance = "2.2.4"
|
||||||
tauri-plugin-single-instance = "2.3.0"
|
tauri-plugin-updater = { version = "2.7.1", default-features = false, features = [
|
||||||
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
|
|
||||||
"rustls-tls",
|
"rustls-tls",
|
||||||
"zip",
|
"zip",
|
||||||
] }
|
] }
|
||||||
tauri-plugin-window-state = "2.3.0"
|
tauri-plugin-window-state = "2.2.2"
|
||||||
tempfile = "3.20.0"
|
tempfile = "3.20.0"
|
||||||
theseus = { path = "packages/app-lib" }
|
theseus = { path = "packages/app-lib" }
|
||||||
thiserror = "2.0.12"
|
thiserror = "2.0.12"
|
||||||
@@ -177,7 +166,7 @@ whoami = "1.6.0"
|
|||||||
winreg = "0.55.0"
|
winreg = "0.55.0"
|
||||||
woothee = "0.13.0"
|
woothee = "0.13.0"
|
||||||
yaserde = "0.12.0"
|
yaserde = "0.12.0"
|
||||||
zip = { version = "4.2.0", default-features = false, features = [
|
zip = { version = "4.0.0", default-features = false, features = [
|
||||||
"bzip2",
|
"bzip2",
|
||||||
"deflate",
|
"deflate",
|
||||||
"deflate64",
|
"deflate64",
|
||||||
@@ -224,7 +213,7 @@ wildcard_dependencies = "warn"
|
|||||||
warnings = "deny"
|
warnings = "deny"
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
wry = { git = "https://github.com/modrinth/wry", rev = "21db186" }
|
wry = { git = "https://github.com/modrinth/wry", rev = "cafdaa9" }
|
||||||
|
|
||||||
# Optimize for speed and reduce size on release builds
|
# Optimize for speed and reduce size on release builds
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
**/dist
|
**/dist
|
||||||
*.gltf
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@modrinth/app-frontend",
|
"name": "@modrinth/app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0-local",
|
"version": "0.9.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -20,20 +20,16 @@
|
|||||||
"@sentry/vue": "^8.27.0",
|
"@sentry/vue": "^8.27.0",
|
||||||
"@tauri-apps/api": "^2.5.0",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||||
"@tauri-apps/plugin-http": "^2.5.0",
|
|
||||||
"@tauri-apps/plugin-opener": "^2.2.6",
|
"@tauri-apps/plugin-opener": "^2.2.6",
|
||||||
"@tauri-apps/plugin-os": "^2.2.1",
|
"@tauri-apps/plugin-os": "^2.2.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.7.1",
|
"@tauri-apps/plugin-updater": "^2.7.1",
|
||||||
"@tauri-apps/plugin-window-state": "^2.2.2",
|
"@tauri-apps/plugin-window-state": "^2.2.2",
|
||||||
"@types/three": "^0.172.0",
|
|
||||||
"@vintl/vintl": "^4.4.1",
|
"@vintl/vintl": "^4.4.1",
|
||||||
"@vueuse/core": "^11.1.0",
|
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"floating-vue": "^5.2.2",
|
"floating-vue": "^5.2.2",
|
||||||
"ofetch": "^1.3.4",
|
"ofetch": "^1.3.4",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"posthog-js": "^1.158.2",
|
"posthog-js": "^1.158.2",
|
||||||
"three": "^0.172.0",
|
|
||||||
"vite-svg-loader": "^5.1.0",
|
"vite-svg-loader": "^5.1.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-multiselect": "3.0.0",
|
"vue-multiselect": "3.0.0",
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
ArrowBigUpDashIcon,
|
ArrowBigUpDashIcon,
|
||||||
ChangeSkinIcon,
|
|
||||||
CompassIcon,
|
CompassIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
@@ -19,7 +18,6 @@ import {
|
|||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
WorldIcon,
|
WorldIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
NewspaperIcon,
|
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -27,7 +25,7 @@ import {
|
|||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
Notifications,
|
Notifications,
|
||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
NewsArticleCard,
|
useRelativeTime,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { useLoading, useTheming } from '@/store/state'
|
import { useLoading, useTheming } from '@/store/state'
|
||||||
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
||||||
@@ -64,13 +62,14 @@ import NavButton from '@/components/ui/NavButton.vue'
|
|||||||
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||||
import { get_user } from '@/helpers/cache.js'
|
import { get_user } from '@/helpers/cache.js'
|
||||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||||
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
|
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
|
||||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||||
import { get_available_capes, get_available_skins } from './helpers/skins'
|
|
||||||
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
const formatRelativeTime = useRelativeTime()
|
||||||
|
|
||||||
const themeStore = useTheming()
|
const themeStore = useTheming()
|
||||||
|
|
||||||
@@ -178,7 +177,6 @@ async function setupApp() {
|
|||||||
'criticalAnnouncements',
|
'criticalAnnouncements',
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.then((response) => response.json())
|
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res && res.header && res.body) {
|
if (res && res.header && res.body) {
|
||||||
criticalErrorMessage.value = res
|
criticalErrorMessage.value = res
|
||||||
@@ -190,35 +188,15 @@ async function setupApp() {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
useFetch(`https://modrinth.com/news/feed/articles.json`, 'news', true)
|
useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => {
|
||||||
.then((response) => response.json())
|
if (res && res.articles) {
|
||||||
.then((res) => {
|
news.value = res.articles
|
||||||
if (res && res.articles) {
|
}
|
||||||
// Format expected by NewsArticleCard component.
|
})
|
||||||
news.value = res.articles
|
|
||||||
.map((article) => ({
|
|
||||||
...article,
|
|
||||||
path: article.link,
|
|
||||||
thumbnail: article.thumbnail,
|
|
||||||
title: article.title,
|
|
||||||
summary: article.summary,
|
|
||||||
date: article.date,
|
|
||||||
}))
|
|
||||||
.slice(0, 4)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
get_opening_command().then(handleCommand)
|
get_opening_command().then(handleCommand)
|
||||||
checkUpdates()
|
checkUpdates()
|
||||||
fetchCredentials()
|
fetchCredentials()
|
||||||
|
|
||||||
try {
|
|
||||||
const skins = (await get_available_skins()) ?? []
|
|
||||||
const capes = (await get_available_capes()) ?? []
|
|
||||||
generateSkinPreviews(skins, capes)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to generate skin previews in app setup.', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stateFailed = ref(false)
|
const stateFailed = ref(false)
|
||||||
@@ -326,7 +304,6 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const accounts = ref(null)
|
const accounts = ref(null)
|
||||||
provide('accountsCard', accounts)
|
|
||||||
|
|
||||||
command_listener(handleCommand)
|
command_listener(handleCommand)
|
||||||
async function handleCommand(e) {
|
async function handleCommand(e) {
|
||||||
@@ -422,9 +399,6 @@ function handleAuxClick(e) {
|
|||||||
>
|
>
|
||||||
<CompassIcon />
|
<CompassIcon />
|
||||||
</NavButton>
|
</NavButton>
|
||||||
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
|
|
||||||
<ChangeSkinIcon />
|
|
||||||
</NavButton>
|
|
||||||
<NavButton
|
<NavButton
|
||||||
v-tooltip.right="'Library'"
|
v-tooltip.right="'Library'"
|
||||||
to="/library"
|
to="/library"
|
||||||
@@ -485,13 +459,13 @@ function handleAuxClick(e) {
|
|||||||
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
|
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
|
||||||
<div class="flex items-center gap-1 ml-3">
|
<div class="flex items-center gap-1 ml-3">
|
||||||
<button
|
<button
|
||||||
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||||
@click="router.back()"
|
@click="router.back()"
|
||||||
>
|
>
|
||||||
<LeftArrowIcon />
|
<LeftArrowIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||||
@click="router.forward()"
|
@click="router.forward()"
|
||||||
>
|
>
|
||||||
<RightArrowIcon />
|
<RightArrowIcon />
|
||||||
@@ -605,20 +579,34 @@ function handleAuxClick(e) {
|
|||||||
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
|
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
|
||||||
</suspense>
|
</suspense>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="news && news.length > 0" class="pt-4 flex flex-col items-center">
|
<div v-if="news && news.length > 0" class="pt-4 flex flex-col">
|
||||||
<h3 class="px-4 text-lg m-0 text-left w-full">News</h3>
|
<h3 class="px-4 text-lg m-0">News</h3>
|
||||||
<div class="px-4 pt-2 space-y-4 flex flex-col items-center w-full">
|
<template v-for="(item, index) in news" :key="`news-${index}`">
|
||||||
<NewsArticleCard
|
<a
|
||||||
v-for="(item, index) in news"
|
:class="`flex flex-col outline-offset-[-4px] hover:bg-[--brand-gradient-border] focus:bg-[--brand-gradient-border] px-4 transition-colors ${index === 0 ? 'pt-2 pb-4' : 'py-4'}`"
|
||||||
:key="`news-${index}`"
|
:href="item.link"
|
||||||
:article="item"
|
target="_blank"
|
||||||
|
rel="external"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="item.thumbnail"
|
||||||
|
alt="News thumbnail"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="w-full aspect-[3/1] object-cover rounded-2xl border-[1px] border-solid border-[--brand-gradient-border]"
|
||||||
|
/>
|
||||||
|
<h4 class="mt-2 mb-0 text-sm leading-none text-contrast font-semibold">
|
||||||
|
{{ item.title }}
|
||||||
|
</h4>
|
||||||
|
<p class="my-1 text-sm text-secondary leading-tight">{{ item.summary }}</p>
|
||||||
|
<p class="text-right text-sm text-secondary opacity-60 leading-tight m-0">
|
||||||
|
{{ formatRelativeTime(dayjs(item.date).toISOString()) }}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
<hr
|
||||||
|
v-if="index !== news.length - 1"
|
||||||
|
class="h-px my-[-2px] mx-4 border-0 m-0 bg-[--brand-gradient-border]"
|
||||||
/>
|
/>
|
||||||
<ButtonStyled color="brand" size="large">
|
</template>
|
||||||
<a href="https://modrinth.com/news" target="_blank" class="my-4">
|
|
||||||
<NewspaperIcon /> View all news
|
|
||||||
</a>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -136,7 +136,7 @@ const filteredResults = computed(() => {
|
|||||||
|
|
||||||
if (sortBy.value === 'Game version') {
|
if (sortBy.value === 'Game version') {
|
||||||
instances.sort((a, b) => {
|
instances.sort((a, b) => {
|
||||||
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
|
return a.game_version.localeCompare(b.game_version)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,17 +213,6 @@ const filteredResults = computed(() => {
|
|||||||
instanceMap.set(entry[0], entry[1])
|
instanceMap.set(entry[0], entry[1])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
|
|
||||||
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
|
|
||||||
if (group.value === 'Game version') {
|
|
||||||
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
|
|
||||||
return a[0].localeCompare(b[0], undefined, { numeric: true })
|
|
||||||
})
|
|
||||||
instanceMap.clear()
|
|
||||||
sortedEntries.forEach((entry) => {
|
|
||||||
instanceMap.set(entry[0], entry[1])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return instanceMap
|
return instanceMap
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,11 +9,13 @@
|
|||||||
<Avatar
|
<Avatar
|
||||||
size="36px"
|
size="36px"
|
||||||
:src="
|
:src="
|
||||||
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
selectedAccount
|
||||||
|
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
|
||||||
|
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col w-full">
|
||||||
<span>{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}</span>
|
<span>{{ selectedAccount ? selectedAccount.username : 'Select account' }}</span>
|
||||||
<span class="text-secondary text-xs">Minecraft account</span>
|
<span class="text-secondary text-xs">Minecraft account</span>
|
||||||
</div>
|
</div>
|
||||||
<DropdownIcon class="w-5 h-5 shrink-0" />
|
<DropdownIcon class="w-5 h-5 shrink-0" />
|
||||||
@@ -26,40 +28,28 @@
|
|||||||
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
|
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
|
||||||
>
|
>
|
||||||
<div v-if="selectedAccount" class="selected account">
|
<div v-if="selectedAccount" class="selected account">
|
||||||
<Avatar size="xs" :src="avatarUrl" />
|
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.id}/128`" />
|
||||||
<div>
|
<div>
|
||||||
<h4>{{ selectedAccount.profile.name }}</h4>
|
<h4>{{ selectedAccount.username }}</h4>
|
||||||
<p>Selected</p>
|
<p>Selected</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.id)">
|
||||||
v-tooltip="'Log out'"
|
|
||||||
icon-only
|
|
||||||
color="raised"
|
|
||||||
@click="logout(selectedAccount.profile.id)"
|
|
||||||
>
|
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="logged-out account">
|
<div v-else class="logged-out account">
|
||||||
<h4>Not signed in</h4>
|
<h4>Not signed in</h4>
|
||||||
<Button
|
<Button v-tooltip="'Log in'" icon-only color="primary" @click="login()">
|
||||||
v-tooltip="'Log in'"
|
<LogInIcon />
|
||||||
:disabled="loginDisabled"
|
|
||||||
icon-only
|
|
||||||
color="primary"
|
|
||||||
@click="login()"
|
|
||||||
>
|
|
||||||
<LogInIcon v-if="!loginDisabled" />
|
|
||||||
<SpinnerIcon v-else class="animate-spin" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="displayAccounts.length > 0" class="account-group">
|
<div v-if="displayAccounts.length > 0" class="account-group">
|
||||||
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
|
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
|
||||||
<Button class="option account" @click="setAccount(account)">
|
<Button class="option account" @click="setAccount(account)">
|
||||||
<Avatar :src="getAccountAvatarUrl(account)" class="icon" />
|
<Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
|
||||||
<p>{{ account.profile.name }}</p>
|
<p>{{ account.username }}</p>
|
||||||
</Button>
|
</Button>
|
||||||
<Button v-tooltip="'Log out'" icon-only @click="logout(account.profile.id)">
|
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +63,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon, SpinnerIcon } from '@modrinth/assets'
|
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon } from '@modrinth/assets'
|
||||||
import { Avatar, Button, Card } from '@modrinth/ui'
|
import { Avatar, Button, Card } from '@modrinth/ui'
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||||
import {
|
import {
|
||||||
@@ -87,8 +77,6 @@ import { handleError } from '@/store/state.js'
|
|||||||
import { trackEvent } from '@/helpers/analytics'
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
import { handleSevereError } from '@/store/error.js'
|
import { handleSevereError } from '@/store/error.js'
|
||||||
import { get_available_skins } from '@/helpers/skins'
|
|
||||||
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
mode: {
|
mode: {
|
||||||
@@ -101,86 +89,32 @@ defineProps({
|
|||||||
const emit = defineEmits(['change'])
|
const emit = defineEmits(['change'])
|
||||||
|
|
||||||
const accounts = ref({})
|
const accounts = ref({})
|
||||||
const loginDisabled = ref(false)
|
|
||||||
const defaultUser = ref()
|
const defaultUser = ref()
|
||||||
const equippedSkin = ref(null)
|
|
||||||
const headUrlCache = ref(new Map())
|
|
||||||
|
|
||||||
async function refreshValues() {
|
async function refreshValues() {
|
||||||
defaultUser.value = await get_default_user().catch(handleError)
|
defaultUser.value = await get_default_user().catch(handleError)
|
||||||
accounts.value = await users().catch(handleError)
|
accounts.value = await users().catch(handleError)
|
||||||
|
|
||||||
try {
|
|
||||||
const skins = await get_available_skins()
|
|
||||||
equippedSkin.value = skins.find((skin) => skin.is_equipped)
|
|
||||||
|
|
||||||
if (equippedSkin.value) {
|
|
||||||
try {
|
|
||||||
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
|
|
||||||
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to get head render for equipped skin:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
equippedSkin.value = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLoginDisabled(value) {
|
|
||||||
loginDisabled.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
refreshValues,
|
refreshValues,
|
||||||
setLoginDisabled,
|
|
||||||
loginDisabled,
|
|
||||||
})
|
})
|
||||||
await refreshValues()
|
await refreshValues()
|
||||||
|
|
||||||
const displayAccounts = computed(() =>
|
const displayAccounts = computed(() =>
|
||||||
accounts.value.filter((account) => defaultUser.value !== account.profile.id),
|
accounts.value.filter((account) => defaultUser.value !== account.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
const avatarUrl = computed(() => {
|
|
||||||
if (equippedSkin.value?.texture_key) {
|
|
||||||
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
|
|
||||||
if (cachedUrl) {
|
|
||||||
return cachedUrl
|
|
||||||
}
|
|
||||||
return `https://mc-heads.net/avatar/${equippedSkin.value.texture_key}/128`
|
|
||||||
}
|
|
||||||
if (selectedAccount.value?.profile?.id) {
|
|
||||||
return `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128`
|
|
||||||
}
|
|
||||||
return 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
|
||||||
})
|
|
||||||
|
|
||||||
function getAccountAvatarUrl(account) {
|
|
||||||
if (
|
|
||||||
account.profile.id === selectedAccount.value?.profile?.id &&
|
|
||||||
equippedSkin.value?.texture_key
|
|
||||||
) {
|
|
||||||
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
|
|
||||||
if (cachedUrl) {
|
|
||||||
return cachedUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `https://mc-heads.net/avatar/${account.profile.id}/128`
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedAccount = computed(() =>
|
const selectedAccount = computed(() =>
|
||||||
accounts.value.find((account) => account.profile.id === defaultUser.value),
|
accounts.value.find((account) => account.id === defaultUser.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function setAccount(account) {
|
async function setAccount(account) {
|
||||||
defaultUser.value = account.profile.id
|
defaultUser.value = account.id
|
||||||
await set_default_user(account.profile.id).catch(handleError)
|
await set_default_user(account.id).catch(handleError)
|
||||||
emit('change')
|
emit('change')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
loginDisabled.value = true
|
|
||||||
const loggedIn = await login_flow().catch(handleSevereError)
|
const loggedIn = await login_flow().catch(handleSevereError)
|
||||||
|
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
@@ -189,7 +123,6 @@ async function login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
trackEvent('AccountLogIn')
|
trackEvent('AccountLogIn')
|
||||||
loginDisabled.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = async (id) => {
|
const logout = async (id) => {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ async function loginMinecraft() {
|
|||||||
const loggedIn = await login_flow()
|
const loggedIn = await login_flow()
|
||||||
|
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
await set_default_user(loggedIn.profile.id).catch(handleError)
|
await set_default_user(loggedIn.id).catch(handleError)
|
||||||
}
|
}
|
||||||
|
|
||||||
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
||||||
@@ -219,8 +219,8 @@ async function copyToClipboard(text) {
|
|||||||
<template v-else-if="metadata.notEnoughSpace">
|
<template v-else-if="metadata.notEnoughSpace">
|
||||||
<h3>Not enough space</h3>
|
<h3>Not enough space</h3>
|
||||||
<p>
|
<p>
|
||||||
It looks like there is not enough space on the disk containing the directory you
|
It looks like there is not enough space on the disk containing the dirctory you
|
||||||
selected. Please free up some space and try again or cancel the directory change.
|
selected Please free up some space and try again or cancel the directory change.
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { showProfileInFolder } from '@/helpers/utils.js'
|
|||||||
import { handleSevereError } from '@/store/error.js'
|
import { handleSevereError } from '@/store/error.js'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import { formatCategory } from '@modrinth/utils'
|
||||||
|
|
||||||
const formatRelativeTime = useRelativeTime()
|
const formatRelativeTime = useRelativeTime()
|
||||||
|
|
||||||
@@ -172,10 +173,7 @@ onUnmounted(() => unlisten())
|
|||||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
|
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
|
||||||
<TimerIcon />
|
<TimerIcon />
|
||||||
<span class="text-sm">
|
<span class="text-sm">
|
||||||
<template v-if="instance.last_played">
|
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
|
||||||
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
|
|
||||||
</template>
|
|
||||||
<template v-else> Never played </template>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,8 +237,8 @@ onUnmounted(() => unlisten())
|
|||||||
</p>
|
</p>
|
||||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
|
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
|
||||||
<GameIcon class="shrink-0" />
|
<GameIcon class="shrink-0" />
|
||||||
<span class="text-sm capitalize">
|
<span class="text-sm">
|
||||||
{{ instance.loader }} {{ instance.game_version }}
|
{{ formatCategory(instance.loader) }} {{ instance.game_version }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ async function testJava() {
|
|||||||
testingJava.value = true
|
testingJava.value = true
|
||||||
testingJavaSuccess.value = await test_jre(
|
testingJavaSuccess.value = await test_jre(
|
||||||
props.modelValue ? props.modelValue.path : '',
|
props.modelValue ? props.modelValue.path : '',
|
||||||
|
1,
|
||||||
props.version,
|
props.version,
|
||||||
)
|
)
|
||||||
testingJava.value = false
|
testingJava.value = false
|
||||||
|
|||||||
@@ -32,33 +32,8 @@ function updateAdPosition() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
|
<div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
|
||||||
<a
|
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
|
||||||
href="https://modrinth.gg?from=app-placeholder"
|
<p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
|
||||||
target="_blank"
|
</div>
|
||||||
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp"
|
|
||||||
alt="Host your next server with Modrinth Servers"
|
|
||||||
class="hidden light-image rounded-[inherit]"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp"
|
|
||||||
alt="Host your next server with Modrinth Servers"
|
|
||||||
class="dark-image rounded-[inherit]"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style lang="scss" scoped>
|
|
||||||
.light,
|
|
||||||
.light-mode {
|
|
||||||
.dark-image {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.light-image {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const getInstances = async () => {
|
|||||||
|
|
||||||
return dateB - dateA
|
return dateB - dateA
|
||||||
})
|
})
|
||||||
.slice(0, 3)
|
.slice(0, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
await getInstances()
|
await getInstances()
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { edit, get_optimal_jre_key } from '@/helpers/profile'
|
|||||||
import { handleError } from '@/store/notifications'
|
import { handleError } from '@/store/notifications'
|
||||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||||
|
import { get_max_memory } from '@/helpers/jre'
|
||||||
import { get } from '@/helpers/settings.ts'
|
import { get } from '@/helpers/settings.ts'
|
||||||
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
|
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
|
||||||
import useMemorySlider from '@/composables/useMemorySlider'
|
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ const envVars = ref(
|
|||||||
|
|
||||||
const overrideMemorySettings = ref(!!props.instance.memory)
|
const overrideMemorySettings = ref(!!props.instance.memory)
|
||||||
const memory = ref(props.instance.memory ?? globalSettings.memory)
|
const memory = ref(props.instance.memory ?? globalSettings.memory)
|
||||||
const { maxMemory, snapPoints } = await useMemorySlider()
|
const maxMemory = Math.floor((await get_max_memory().catch(handleError)) / 1024)
|
||||||
|
|
||||||
const editProfileObject = computed(() => {
|
const editProfileObject = computed(() => {
|
||||||
const editProfile: {
|
const editProfile: {
|
||||||
@@ -156,8 +156,6 @@ const messages = defineMessages({
|
|||||||
:min="512"
|
:min="512"
|
||||||
:max="maxMemory"
|
:max="maxMemory"
|
||||||
:step="64"
|
:step="64"
|
||||||
:snap-points="snapPoints"
|
|
||||||
:snap-range="512"
|
|
||||||
unit="MB"
|
unit="MB"
|
||||||
/>
|
/>
|
||||||
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
GaugeIcon,
|
GaugeIcon,
|
||||||
PaintbrushIcon,
|
PaintBrushIcon,
|
||||||
GameIcon,
|
GameIcon,
|
||||||
CoffeeIcon,
|
CoffeeIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
@@ -41,7 +41,7 @@ const tabs = [
|
|||||||
id: 'app.settings.tabs.appearance',
|
id: 'app.settings.tabs.appearance',
|
||||||
defaultMessage: 'Appearance',
|
defaultMessage: 'Appearance',
|
||||||
}),
|
}),
|
||||||
icon: PaintbrushIcon,
|
icon: PaintBrushIcon,
|
||||||
content: AppearanceSettings,
|
content: AppearanceSettings,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTemplateRef } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { NewModal as Modal } from '@modrinth/ui'
|
import { NewModal as Modal } from '@modrinth/ui'
|
||||||
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
|
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
|
||||||
import { useTheming } from '@/store/theme.ts'
|
import { useTheming } from '@/store/theme.ts'
|
||||||
@@ -26,16 +26,16 @@ const props = defineProps({
|
|||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const modal = useTemplateRef('modal')
|
const modal = ref(null)
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
show: (e: MouseEvent) => {
|
show: () => {
|
||||||
hide_ads_window()
|
hide_ads_window()
|
||||||
modal.value?.show(e)
|
modal.value.show()
|
||||||
},
|
},
|
||||||
hide: () => {
|
hide: () => {
|
||||||
onModalHide()
|
onModalHide()
|
||||||
modal.value?.hide()
|
modal.value.hide()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -56,17 +56,9 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2>
|
|
||||||
<p class="m-0 mt-1">Disables the nametag above your player on the skins page.</p>
|
|
||||||
</div>
|
|
||||||
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
|
<div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Native decorations</h2>
|
<h2 class="m-0 text-lg font-extrabold text-contrast">Native Decorations</h2>
|
||||||
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
|
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle id="native-decorations" v-model="settings.native_decorations" />
|
<Toggle id="native-decorations" v-model="settings.native_decorations" />
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { get, set } from '@/helpers/settings.ts'
|
import { get, set } from '@/helpers/settings.ts'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import { get_max_memory } from '@/helpers/jre'
|
||||||
|
import { handleError } from '@/store/notifications'
|
||||||
import { Slider, Toggle } from '@modrinth/ui'
|
import { Slider, Toggle } from '@modrinth/ui'
|
||||||
import useMemorySlider from '@/composables/useMemorySlider'
|
|
||||||
|
|
||||||
const fetchSettings = await get()
|
const fetchSettings = await get()
|
||||||
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
|
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
|
||||||
@@ -10,7 +11,7 @@ fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).jo
|
|||||||
|
|
||||||
const settings = ref(fetchSettings)
|
const settings = ref(fetchSettings)
|
||||||
|
|
||||||
const { maxMemory, snapPoints } = await useMemorySlider()
|
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
settings,
|
settings,
|
||||||
@@ -106,8 +107,6 @@ watch(
|
|||||||
:min="512"
|
:min="512"
|
||||||
:max="maxMemory"
|
:max="maxMemory"
|
||||||
:step="64"
|
:step="64"
|
||||||
:snap-points="snapPoints"
|
|
||||||
:snap-range="512"
|
|
||||||
unit="MB"
|
unit="MB"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
<script setup lang="ts">
|
||||||
import type { Dayjs } from 'dayjs'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import {
|
import {
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
@@ -43,7 +42,6 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
instance: GameInstance
|
instance: GameInstance
|
||||||
last_played: Dayjs
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const loadingModpack = ref(!!props.instance.linked_data)
|
const loadingModpack = ref(!!props.instance.linked_data)
|
||||||
@@ -149,12 +147,12 @@ onUnmounted(() => {
|
|||||||
: null
|
: null
|
||||||
"
|
"
|
||||||
class="w-fit shrink-0"
|
class="w-fit shrink-0"
|
||||||
:class="{ 'cursor-help smart-clickable:allow-pointer-events': last_played }"
|
:class="{ 'cursor-help smart-clickable:allow-pointer-events': instance.last_played }"
|
||||||
>
|
>
|
||||||
<template v-if="last_played">
|
<template v-if="instance.last_played">
|
||||||
{{
|
{{
|
||||||
formatMessage(commonMessages.playedLabel, {
|
formatMessage(commonMessages.playedLabel, {
|
||||||
time: formatRelativeTime(last_played.toISOString?.()),
|
time: formatRelativeTime(instance.last_played.toISOString()),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ async function populateJumpBackIn() {
|
|||||||
|
|
||||||
worldItems.push({
|
worldItems.push({
|
||||||
type: 'world',
|
type: 'world',
|
||||||
last_played: dayjs(world.last_played ?? 0),
|
last_played: dayjs(world.last_played),
|
||||||
world: world,
|
world: world,
|
||||||
instance: instance,
|
instance: instance,
|
||||||
})
|
})
|
||||||
@@ -138,13 +138,13 @@ async function populateJumpBackIn() {
|
|||||||
|
|
||||||
instanceItems.push({
|
instanceItems.push({
|
||||||
type: 'instance',
|
type: 'instance',
|
||||||
last_played: dayjs(instance.last_played ?? 0),
|
last_played: dayjs(instance.last_played),
|
||||||
instance: instance,
|
instance: instance,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
|
const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
|
||||||
items.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0)))
|
items.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played)))
|
||||||
jumpBackInItems.value = items
|
jumpBackInItems.value = items
|
||||||
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO))
|
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO))
|
||||||
.slice(0, MAX_JUMP_BACK_IN)
|
.slice(0, MAX_JUMP_BACK_IN)
|
||||||
@@ -291,7 +291,7 @@ onUnmounted(() => {
|
|||||||
"
|
"
|
||||||
@stop="() => stopInstance(item.instance.path)"
|
@stop="() => stopInstance(item.instance.path)"
|
||||||
/>
|
/>
|
||||||
<InstanceItem v-else :instance="item.instance" :last_played="item.last_played" />
|
<InstanceItem v-else :instance="item.instance" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -128,14 +128,6 @@ const messages = defineMessages({
|
|||||||
id: 'instance.worlds.game_already_open',
|
id: 'instance.worlds.game_already_open',
|
||||||
defaultMessage: 'Instance is already open',
|
defaultMessage: 'Instance is already open',
|
||||||
},
|
},
|
||||||
noContact: {
|
|
||||||
id: 'instance.worlds.no_contact',
|
|
||||||
defaultMessage: "Server couldn't be contacted",
|
|
||||||
},
|
|
||||||
incompatibleServer: {
|
|
||||||
id: 'instance.worlds.incompatible_server',
|
|
||||||
defaultMessage: 'Server is incompatible',
|
|
||||||
},
|
|
||||||
copyAddress: {
|
copyAddress: {
|
||||||
id: 'instance.worlds.copy_address',
|
id: 'instance.worlds.copy_address',
|
||||||
defaultMessage: 'Copy address',
|
defaultMessage: 'Copy address',
|
||||||
@@ -310,33 +302,39 @@ const messages = defineMessages({
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
||||||
<ButtonStyled
|
<template v-if="world.type === 'singleplayer' || serverStatus">
|
||||||
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
<ButtonStyled
|
||||||
color="red"
|
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
||||||
>
|
color="red"
|
||||||
<button @click="emit('stop')">
|
>
|
||||||
<StopCircleIcon aria-hidden="true" />
|
<button @click="emit('stop')">
|
||||||
{{ formatMessage(commonMessages.stopButton) }}
|
<StopCircleIcon aria-hidden="true" />
|
||||||
</button>
|
{{ formatMessage(commonMessages.stopButton) }}
|
||||||
</ButtonStyled>
|
</button>
|
||||||
<ButtonStyled v-else>
|
</ButtonStyled>
|
||||||
<button
|
<ButtonStyled v-else>
|
||||||
v-tooltip="
|
<button
|
||||||
!serverStatus
|
v-tooltip="
|
||||||
? formatMessage(messages.noContact)
|
serverIncompatible
|
||||||
: serverIncompatible
|
? 'Server is incompatible'
|
||||||
? formatMessage(messages.incompatibleServer)
|
|
||||||
: !supportsQuickPlay
|
: !supportsQuickPlay
|
||||||
? formatMessage(messages.noQuickPlay)
|
? formatMessage(messages.noQuickPlay)
|
||||||
: playingOtherWorld || locked
|
: playingOtherWorld || locked
|
||||||
? formatMessage(messages.gameAlreadyOpen)
|
? formatMessage(messages.gameAlreadyOpen)
|
||||||
: null
|
: null
|
||||||
"
|
"
|
||||||
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
||||||
@click="emit('play')"
|
@click="emit('play')"
|
||||||
>
|
>
|
||||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||||
<PlayIcon v-else aria-hidden="true" />
|
<PlayIcon v-else aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonMessages.playButton) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
<ButtonStyled v-else>
|
||||||
|
<button class="invisible">
|
||||||
|
<PlayIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonMessages.playButton) }}
|
{{ formatMessage(commonMessages.playButton) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
|
|||||||
@@ -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 { handleError } from '@/store/state.js'
|
||||||
import { getVersion } from '@tauri-apps/api/app'
|
import { getVersion } from '@tauri-apps/api/app'
|
||||||
|
|
||||||
export const useFetch = async (url, item, isSilent) => {
|
export const useFetch = async (url, item, isSilent) => {
|
||||||
try {
|
try {
|
||||||
const version = await getVersion()
|
const version = await getVersion()
|
||||||
return await fetch(url, {
|
|
||||||
method: 'GET',
|
return await ofetch(url, {
|
||||||
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
|
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export async function get_jre(path) {
|
|||||||
|
|
||||||
// Tests JRE version by running 'java -version' on it.
|
// Tests JRE version by running 'java -version' on it.
|
||||||
// Returns true if the version is valid, and matches given (after extraction)
|
// Returns true if the version is valid, and matches given (after extraction)
|
||||||
export async function test_jre(path, majorVersion) {
|
export async function test_jre(path, majorVersion, minorVersion) {
|
||||||
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion })
|
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion, minorVersion })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically installs specified java version
|
// Automatically installs specified java version
|
||||||
|
|||||||
@@ -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
|
theme: ColorTheme
|
||||||
default_page: 'home' | 'library'
|
default_page: 'home' | 'library'
|
||||||
collapsed_navigation: boolean
|
collapsed_navigation: boolean
|
||||||
hide_nametag_skins_page: boolean
|
|
||||||
advanced_rendering: boolean
|
advanced_rendering: boolean
|
||||||
native_decorations: boolean
|
native_decorations: boolean
|
||||||
toggle_sidebar: boolean
|
toggle_sidebar: boolean
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -377,12 +377,6 @@
|
|||||||
"instance.worlds.hardcore": {
|
"instance.worlds.hardcore": {
|
||||||
"message": "Hardcore mode"
|
"message": "Hardcore mode"
|
||||||
},
|
},
|
||||||
"instance.worlds.incompatible_server": {
|
|
||||||
"message": "Server is incompatible"
|
|
||||||
},
|
|
||||||
"instance.worlds.no_contact": {
|
|
||||||
"message": "Server couldn't be contacted"
|
|
||||||
},
|
|
||||||
"instance.worlds.no_quick_play": {
|
"instance.worlds.no_quick_play": {
|
||||||
"message": "You can only jump straight into worlds on Minecraft 1.20+"
|
"message": "You can only jump straight into worlds on Minecraft 1.20+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -220,7 +220,6 @@ async function refreshSearch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
results.value = rawResults.result
|
results.value = rawResults.result
|
||||||
currentPage.value = 1
|
|
||||||
|
|
||||||
const persistentParams: LocationQuery = {}
|
const persistentParams: LocationQuery = {}
|
||||||
|
|
||||||
@@ -266,7 +265,6 @@ async function onSearchChangeToTop() {
|
|||||||
|
|
||||||
function clearSearch() {
|
function clearSearch() {
|
||||||
query.value = ''
|
query.value = ''
|
||||||
currentPage.value = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import dayjs from 'dayjs'
|
|||||||
import { get_search_results } from '@/helpers/cache.js'
|
import { get_search_results } from '@/helpers/cache.js'
|
||||||
import type { SearchResult } from '@modrinth/utils'
|
import type { SearchResult } from '@modrinth/utils'
|
||||||
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
|
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const breadcrumbs = useBreadcrumbs()
|
const breadcrumbs = useBreadcrumbs()
|
||||||
@@ -83,15 +82,13 @@ async function refreshFeaturedProjects() {
|
|||||||
await fetchInstances()
|
await fetchInstances()
|
||||||
await refreshFeaturedProjects()
|
await refreshFeaturedProjects()
|
||||||
|
|
||||||
const unlistenProfile = await profile_listener(
|
const unlistenProfile = await profile_listener(async (e) => {
|
||||||
async (e: { event: string; profile_path_id: string }) => {
|
await fetchInstances()
|
||||||
await fetchInstances()
|
|
||||||
|
|
||||||
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
||||||
await refreshFeaturedProjects()
|
await refreshFeaturedProjects()
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
unlistenProfile()
|
unlistenProfile()
|
||||||
@@ -100,8 +97,8 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 flex flex-col gap-2">
|
<div class="p-6 flex flex-col gap-2">
|
||||||
<h1 v-if="recentInstances?.length > 0" class="m-0 text-2xl font-extrabold">Welcome back!</h1>
|
<h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
|
||||||
<h1 v-else class="m-0 text-2xl font-extrabold">Welcome to Modrinth App!</h1>
|
<h1 v-else class="m-0 text-2xl">Welcome to Modrinth App!</h1>
|
||||||
<RecentWorldsList :recent-instances="recentInstances" />
|
<RecentWorldsList :recent-instances="recentInstances" />
|
||||||
<RowDisplay
|
<RowDisplay
|
||||||
v-if="hasFeaturedProjects"
|
v-if="hasFeaturedProjects"
|
||||||
|
|||||||
@@ -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 Index from './Index.vue'
|
||||||
import Browse from './Browse.vue'
|
import Browse from './Browse.vue'
|
||||||
import Worlds from './Worlds.vue'
|
import Worlds from './Worlds.vue'
|
||||||
import Skins from './Skins.vue'
|
|
||||||
|
|
||||||
export { Index, Browse, Worlds, Skins }
|
export { Index, Browse, Worlds }
|
||||||
|
|||||||
@@ -483,7 +483,7 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
height: 100vh;
|
height: calc(100vh - 11rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-row {
|
.button-row {
|
||||||
|
|||||||
@@ -34,14 +34,6 @@ export default new createRouter({
|
|||||||
breadcrumb: [{ name: 'Discover content' }],
|
breadcrumb: [{ name: 'Discover content' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/skins',
|
|
||||||
name: 'Skins',
|
|
||||||
component: Pages.Skins,
|
|
||||||
meta: {
|
|
||||||
breadcrumb: [{ name: 'Skins' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/library',
|
path: '/library',
|
||||||
name: 'Library',
|
name: 'Library',
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export default {
|
|||||||
green: 'var(--color-green-highlight)',
|
green: 'var(--color-green-highlight)',
|
||||||
blue: 'var(--color-blue-highlight)',
|
blue: 'var(--color-blue-highlight)',
|
||||||
purple: 'var(--color-purple-highlight)',
|
purple: 'var(--color-purple-highlight)',
|
||||||
gray: 'var(--color-gray-highlight)',
|
|
||||||
},
|
},
|
||||||
divider: {
|
divider: {
|
||||||
DEFAULT: 'var(--color-divider)',
|
DEFAULT: 'var(--color-divider)',
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"resolveJsonModule": true,
|
|
||||||
|
|
||||||
"strict": true
|
"strict": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import svgLoader from 'vite-svg-loader'
|
|||||||
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
import tauriConf from '../app/tauri.conf.json'
|
|
||||||
|
|
||||||
const projectRootDir = resolve(__dirname)
|
const projectRootDir = resolve(__dirname)
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
@@ -43,32 +41,17 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 1420,
|
port: 1420,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
headers: {
|
|
||||||
'content-security-policy': Object.entries(tauriConf.app.security.csp)
|
|
||||||
.map(([directive, sources]) => {
|
|
||||||
// An additional websocket connect-src is required for Vite dev tools to work
|
|
||||||
if (directive === 'connect-src') {
|
|
||||||
sources = Array.isArray(sources) ? sources : [sources]
|
|
||||||
sources.push('ws://localhost:1420')
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.isArray(sources)
|
|
||||||
? `${directive} ${sources.join(' ')}`
|
|
||||||
: `${directive} ${sources}`
|
|
||||||
})
|
|
||||||
.join('; '),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// to make use of `TAURI_ENV_DEBUG` and other env variables
|
// to make use of `TAURI_ENV_DEBUG` and other env variables
|
||||||
// https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands
|
// https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands
|
||||||
envPrefix: ['VITE_', 'TAURI_'],
|
envPrefix: ['VITE_', 'TAURI_'],
|
||||||
build: {
|
build: {
|
||||||
// Tauri supports es2021
|
// Tauri supports es2021
|
||||||
target: process.env.TAURI_ENV_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
|
target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
|
||||||
// don't minify for debug builds
|
// don't minify for debug builds
|
||||||
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
|
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
|
||||||
// produce sourcemaps for debug builds
|
// produce sourcemaps for debug builds
|
||||||
sourcemap: !!process.env.TAURI_ENV_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
|
sourcemap: !!process.env.TAURI_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
|
||||||
commonjsOptions: {
|
commonjsOptions: {
|
||||||
esmExternals: true,
|
esmExternals: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,10 +27,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
|||||||
|
|
||||||
let credentials = minecraft_auth::finish_login(&input, login).await?;
|
let credentials = minecraft_auth::finish_login(&input, login).await?;
|
||||||
|
|
||||||
println!(
|
println!("Logged in user {}.", credentials.username);
|
||||||
"Logged in user {}.",
|
|
||||||
credentials.maybe_online_profile().await.name
|
|
||||||
);
|
|
||||||
Ok(credentials)
|
Ok(credentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
4
apps/app/.gitignore
vendored
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
|
# Generated by tauri, metadata generated at compile time
|
||||||
/gen/
|
/gen/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "theseus_gui"
|
name = "theseus_gui"
|
||||||
version = "1.0.0-local" # The actual version is set by the theseus-build workflow on tagging
|
version = "0.9.5"
|
||||||
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
|
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
repository = "https://github.com/modrinth/code/apps/app/"
|
repository = "https://github.com/modrinth/code/apps/app/"
|
||||||
@@ -17,14 +17,13 @@ serde = { workspace = true, features = ["derive"] }
|
|||||||
serde_with.workspace = true
|
serde_with.workspace = true
|
||||||
|
|
||||||
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] }
|
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] }
|
||||||
tauri-plugin-deep-link.workspace = true
|
|
||||||
tauri-plugin-dialog.workspace = true
|
|
||||||
tauri-plugin-http.workspace = true
|
|
||||||
tauri-plugin-opener.workspace = true
|
|
||||||
tauri-plugin-os.workspace = true
|
|
||||||
tauri-plugin-single-instance.workspace = true
|
|
||||||
tauri-plugin-updater.workspace = true
|
|
||||||
tauri-plugin-window-state.workspace = true
|
tauri-plugin-window-state.workspace = true
|
||||||
|
tauri-plugin-deep-link.workspace = true
|
||||||
|
tauri-plugin-os.workspace = true
|
||||||
|
tauri-plugin-opener.workspace = true
|
||||||
|
tauri-plugin-dialog.workspace = true
|
||||||
|
tauri-plugin-updater.workspace = true
|
||||||
|
tauri-plugin-single-instance.workspace = true
|
||||||
|
|
||||||
tokio = { workspace = true, features = ["time"] }
|
tokio = { workspace = true, features = ["time"] }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|||||||
@@ -18,25 +18,5 @@
|
|||||||
<string>A Minecraft mod wants to access your camera.</string>
|
<string>A Minecraft mod wants to access your camera.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>A Minecraft mod wants to access your microphone.</string>
|
<string>A Minecraft mod wants to access your microphone.</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSExceptionDomains</key>
|
|
||||||
<dict>
|
|
||||||
<key>asset.localhost</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSIncludesSubdomains</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
<key>textures.minecraft.net</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSIncludesSubdomains</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -99,24 +99,6 @@ fn main() {
|
|||||||
DefaultPermissionRule::AllowAllCommands,
|
DefaultPermissionRule::AllowAllCommands,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.plugin(
|
|
||||||
"minecraft-skins",
|
|
||||||
InlinedPlugin::new()
|
|
||||||
.commands(&[
|
|
||||||
"get_available_capes",
|
|
||||||
"get_available_skins",
|
|
||||||
"add_and_equip_custom_skin",
|
|
||||||
"set_default_cape",
|
|
||||||
"equip_skin",
|
|
||||||
"remove_custom_skin",
|
|
||||||
"unequip_skin",
|
|
||||||
"normalize_skin_texture",
|
|
||||||
"get_dragged_skin_data",
|
|
||||||
])
|
|
||||||
.default_permission(
|
|
||||||
DefaultPermissionRule::AllowAllCommands,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.plugin(
|
.plugin(
|
||||||
"mr-auth",
|
"mr-auth",
|
||||||
InlinedPlugin::new()
|
InlinedPlugin::new()
|
||||||
@@ -169,6 +151,7 @@ fn main() {
|
|||||||
"profile_update_managed_modrinth_version",
|
"profile_update_managed_modrinth_version",
|
||||||
"profile_repair_managed_modrinth",
|
"profile_repair_managed_modrinth",
|
||||||
"profile_run",
|
"profile_run",
|
||||||
|
"profile_run_credentials",
|
||||||
"profile_kill",
|
"profile_kill",
|
||||||
"profile_edit",
|
"profile_edit",
|
||||||
"profile_edit_icon",
|
"profile_edit_icon",
|
||||||
|
|||||||
@@ -19,21 +19,12 @@
|
|||||||
"window-state:default",
|
"window-state:default",
|
||||||
"window-state:allow-restore-state",
|
"window-state:allow-restore-state",
|
||||||
"window-state:allow-save-window-state",
|
"window-state:allow-save-window-state",
|
||||||
|
|
||||||
{
|
|
||||||
"identifier": "http:default",
|
|
||||||
"allow": [
|
|
||||||
{ "url": "https://modrinth.com/*" },
|
|
||||||
{ "url": "https://*.modrinth.com/*" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
"auth:default",
|
"auth:default",
|
||||||
"import:default",
|
"import:default",
|
||||||
"jre:default",
|
"jre:default",
|
||||||
"logs:default",
|
"logs:default",
|
||||||
"metadata:default",
|
"metadata:default",
|
||||||
"minecraft-skins:default",
|
|
||||||
"mr-auth:default",
|
"mr-auth:default",
|
||||||
"profile-create:default",
|
"profile-create:default",
|
||||||
"pack:default",
|
"pack:default",
|
||||||
|
|||||||
@@ -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 jre;
|
||||||
pub mod logs;
|
pub mod logs;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod minecraft_skins;
|
|
||||||
pub mod mr_auth;
|
pub mod mr_auth;
|
||||||
pub mod pack;
|
pub mod pack;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
profile_update_managed_modrinth_version,
|
profile_update_managed_modrinth_version,
|
||||||
profile_repair_managed_modrinth,
|
profile_repair_managed_modrinth,
|
||||||
profile_run,
|
profile_run,
|
||||||
|
profile_run_credentials,
|
||||||
profile_kill,
|
profile_kill,
|
||||||
profile_edit,
|
profile_edit,
|
||||||
profile_edit_icon,
|
profile_edit_icon,
|
||||||
@@ -255,6 +256,22 @@ pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
|
|||||||
Ok(process)
|
Ok(process)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run Minecraft using a profile using chosen credentials
|
||||||
|
// Returns the UUID, which can be used to poll
|
||||||
|
// for the actual Child in the state.
|
||||||
|
// invoke('plugin:profile|profile_run_credentials', {path, credentials})')
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn profile_run_credentials(
|
||||||
|
path: &str,
|
||||||
|
credentials: Credentials,
|
||||||
|
) -> Result<ProcessMetadata> {
|
||||||
|
let process =
|
||||||
|
profile::run_credentials(path, &credentials, &QuickPlayType::None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(process)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn profile_kill(path: &str) -> Result<()> {
|
pub async fn profile_kill(path: &str) -> Result<()> {
|
||||||
profile::kill(path).await?;
|
profile::kill(path).await?;
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ fn main() {
|
|||||||
let _ = win.set_focus();
|
let _ = win.set_focus();
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.plugin(tauri_plugin_http::init())
|
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_deep_link::init())
|
.plugin(tauri_plugin_deep_link::init())
|
||||||
@@ -249,7 +248,6 @@ fn main() {
|
|||||||
.plugin(api::logs::init())
|
.plugin(api::logs::init())
|
||||||
.plugin(api::jre::init())
|
.plugin(api::jre::init())
|
||||||
.plugin(api::metadata::init())
|
.plugin(api::metadata::init())
|
||||||
.plugin(api::minecraft_skins::init())
|
|
||||||
.plugin(api::pack::init())
|
.plugin(api::pack::init())
|
||||||
.plugin(api::process::init())
|
.plugin(api::process::init())
|
||||||
.plugin(api::profile::init())
|
.plugin(api::profile::init())
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"productName": "Modrinth App",
|
"productName": "Modrinth App",
|
||||||
"version": "../app-frontend/package.json",
|
"version": "0.9.5",
|
||||||
"mainBinaryName": "Modrinth App",
|
"mainBinaryName": "Modrinth App",
|
||||||
"identifier": "ModrinthApp",
|
"identifier": "ModrinthApp",
|
||||||
"plugins": {
|
"plugins": {
|
||||||
@@ -86,9 +86,9 @@
|
|||||||
"capabilities": ["ads", "core", "plugins"],
|
"capabilities": ["ads", "core", "plugins"],
|
||||||
"csp": {
|
"csp": {
|
||||||
"default-src": "'self' customprotocol: asset:",
|
"default-src": "'self' customprotocol: asset:",
|
||||||
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:",
|
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs",
|
||||||
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
||||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:",
|
||||||
"style-src": "'unsafe-inline' 'self'",
|
"style-src": "'unsafe-inline' 'self'",
|
||||||
"script-src": "https://*.posthog.com 'self'",
|
"script-src": "https://*.posthog.com 'self'",
|
||||||
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'",
|
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
FROM rust:1.88.0 AS build
|
FROM rust:1.87.0 AS build
|
||||||
|
ENV PKG_CONFIG_ALLOW_CROSS=1
|
||||||
|
|
||||||
WORKDIR /usr/src/daedalus
|
WORKDIR /usr/src/daedalus
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -9,8 +10,11 @@ FROM debian:bookworm-slim
|
|||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
||||||
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN update-ca-certificates
|
||||||
|
|
||||||
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
||||||
WORKDIR /daedalus_client
|
WORKDIR /daedalus_client
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
Support: https://support.modrinth.com
|
Support: https://support.modrinth.com
|
||||||
Status page: https://status.modrinth.com
|
Status page: https://status.modrinth.com
|
||||||
Roadmap: https://roadmap.modrinth.com
|
Roadmap: https://roadmap.modrinth.com
|
||||||
Blog and newsletter: https://modrinth.com/news
|
Blog and newsletter: https://blog.modrinth.com/subscribe?utm_medium=social&utm_source=discord&utm_campaign=welcome
|
||||||
API documentation: https://docs.modrinth.com
|
API documentation: https://docs.modrinth.com
|
||||||
Modrinth source code: https://github.com/modrinth
|
Modrinth source code: https://github.com/modrinth
|
||||||
Help translate Modrinth: https://crowdin.com/project/modrinth
|
Help translate Modrinth: https://crowdin.com/project/modrinth
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ From there, you can create the database and perform all database migrations with
|
|||||||
sqlx database setup
|
sqlx database setup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Finally, if on Linux, you will need the OpenSSL library. On Debian-based systems, this involves the `pkg-config` and `libssl-dev` packages.
|
||||||
|
|
||||||
To enable labrinth to create a project, you need to add two things.
|
To enable labrinth to create a project, you need to add two things.
|
||||||
|
|
||||||
1. An entry in the `loaders` table.
|
1. An entry in the `loaders` table.
|
||||||
|
|||||||
@@ -38,12 +38,9 @@
|
|||||||
"@intercom/messenger-js-sdk": "^0.0.14",
|
"@intercom/messenger-js-sdk": "^0.0.14",
|
||||||
"@ltd/j-toml": "^1.38.0",
|
"@ltd/j-toml": "^1.38.0",
|
||||||
"@modrinth/assets": "workspace:*",
|
"@modrinth/assets": "workspace:*",
|
||||||
"@modrinth/blog": "workspace:*",
|
|
||||||
"@modrinth/moderation": "workspace:*",
|
|
||||||
"@modrinth/ui": "workspace:*",
|
"@modrinth/ui": "workspace:*",
|
||||||
"@modrinth/utils": "workspace:*",
|
"@modrinth/utils": "workspace:*",
|
||||||
"@pinia/nuxt": "^0.5.1",
|
"@pinia/nuxt": "^0.5.1",
|
||||||
"@types/three": "^0.172.0",
|
|
||||||
"@vintl/vintl": "^4.4.1",
|
"@vintl/vintl": "^4.4.1",
|
||||||
"@vueuse/core": "^11.1.0",
|
"@vueuse/core": "^11.1.0",
|
||||||
"ace-builds": "^1.36.2",
|
"ace-builds": "^1.36.2",
|
||||||
@@ -59,10 +56,10 @@
|
|||||||
"markdown-it": "14.1.0",
|
"markdown-it": "14.1.0",
|
||||||
"pathe": "^1.1.2",
|
"pathe": "^1.1.2",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"prettier": "^3.6.2",
|
|
||||||
"qrcode.vue": "^3.4.0",
|
"qrcode.vue": "^3.4.0",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"three": "^0.172.0",
|
"three": "^0.172.0",
|
||||||
|
"@types/three": "^0.172.0",
|
||||||
"vue-multiselect": "3.0.0-alpha.2",
|
"vue-multiselect": "3.0.0-alpha.2",
|
||||||
"vue-typed-virtual-list": "^1.0.10",
|
"vue-typed-virtual-list": "^1.0.10",
|
||||||
"vue3-ace-editor": "^2.2.4",
|
"vue3-ace-editor": "^2.2.4",
|
||||||
|
|||||||
@@ -197,13 +197,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
> :where(
|
> :where(
|
||||||
input + *,
|
input + *,
|
||||||
.input-group + *,
|
.input-group + *,
|
||||||
.textarea-wrapper + *,
|
.textarea-wrapper + *,
|
||||||
.chips + *,
|
.chips + *,
|
||||||
.resizable-textarea-wrapper + *,
|
.resizable-textarea-wrapper + *,
|
||||||
.input-div + *
|
.input-div + *
|
||||||
) {
|
) {
|
||||||
&:not(:empty) {
|
&:not(:empty) {
|
||||||
margin-block-start: var(--spacing-card-md);
|
margin-block-start: var(--spacing-card-md);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,12 +115,10 @@ html {
|
|||||||
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15);
|
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15);
|
||||||
|
|
||||||
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
|
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
|
||||||
--shadow-raised:
|
--shadow-raised: 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
|
||||||
0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
|
|
||||||
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
|
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
|
||||||
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
|
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
|
||||||
--shadow-floating:
|
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||||
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
|
||||||
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
|
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
|
||||||
|
|
||||||
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
|
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
|
||||||
@@ -152,8 +150,8 @@ html {
|
|||||||
rgba(255, 255, 255, 0.35) 0%,
|
rgba(255, 255, 255, 0.35) 0%,
|
||||||
rgba(255, 255, 255, 0.2695) 100%
|
rgba(255, 255, 255, 0.2695) 100%
|
||||||
);
|
);
|
||||||
--landing-blob-shadow:
|
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16),
|
||||||
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(255, 255, 255, 0.45);
|
inset 2px 2px 64px rgba(255, 255, 255, 0.45);
|
||||||
|
|
||||||
--landing-card-bg: rgba(255, 255, 255, 0.8);
|
--landing-card-bg: rgba(255, 255, 255, 0.8);
|
||||||
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
||||||
@@ -253,15 +251,13 @@ html {
|
|||||||
|
|
||||||
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
|
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
|
||||||
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
|
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
|
||||||
--shadow-floating:
|
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||||
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
|
||||||
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
|
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
|
||||||
|
|
||||||
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
|
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
|
||||||
|
|
||||||
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp");
|
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp");
|
||||||
--landing-maze-gradient-bg:
|
--landing-maze-gradient-bg: linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
|
||||||
linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
|
|
||||||
url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
|
url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
|
||||||
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
|
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
|
||||||
|
|
||||||
@@ -288,8 +284,7 @@ html {
|
|||||||
rgba(44, 48, 79, 0.35) 0%,
|
rgba(44, 48, 79, 0.35) 0%,
|
||||||
rgba(32, 35, 50, 0.2695) 100%
|
rgba(32, 35, 50, 0.2695) 100%
|
||||||
);
|
);
|
||||||
--landing-blob-shadow:
|
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
|
||||||
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
|
|
||||||
|
|
||||||
--landing-card-bg: rgba(59, 63, 85, 0.15);
|
--landing-card-bg: rgba(59, 63, 85, 0.15);
|
||||||
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
||||||
@@ -365,9 +360,8 @@ body {
|
|||||||
// Defaults
|
// Defaults
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
--font-standard:
|
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
|
||||||
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
|
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||||
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
|
||||||
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
|
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
|
||||||
font-family: var(--font-standard);
|
font-family: var(--font-standard);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
|
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
|
||||||
<nuxt-link
|
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
|
||||||
to="/servers"
|
<p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
|
||||||
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
|
<nuxt-link to="/plus" class="mt-auto items-center gap-1 text-purple hover:underline">
|
||||||
>
|
<span>
|
||||||
<img
|
Support creators and Modrinth ad-free with
|
||||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp"
|
<span class="font-bold">Modrinth+</span>
|
||||||
alt="Host your next server with Modrinth Servers"
|
</span>
|
||||||
class="light-image hidden rounded-[inherit]"
|
<ChevronRightIcon class="relative top-[3px] h-5 w-5" />
|
||||||
/>
|
</nuxt-link>
|
||||||
<img
|
</div>
|
||||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp"
|
|
||||||
alt="Host your next server with Modrinth Servers"
|
|
||||||
class="dark-image rounded-[inherit]"
|
|
||||||
/>
|
|
||||||
</nuxt-link>
|
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
|
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
|
||||||
>
|
>
|
||||||
@@ -23,6 +18,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ChevronRightIcon } from "@modrinth/assets";
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
script: [
|
script: [
|
||||||
// {
|
// {
|
||||||
@@ -140,16 +137,3 @@ iframe[id^="google_ads_iframe"] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.light,
|
|
||||||
.light-mode {
|
|
||||||
.dark-image {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.light-image {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</h1>
|
</h1>
|
||||||
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
|
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
|
||||||
<button v-tooltip="`Exit moderation`" @click="exitModeration">
|
<button v-tooltip="`Exit moderation`" @click="exitModeration">
|
||||||
<XIcon />
|
<CrossIcon />
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled circular>
|
<ButtonStyled circular>
|
||||||
@@ -306,7 +306,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ButtonStyled v-if="!done">
|
<ButtonStyled v-if="!done">
|
||||||
<button aria-label="Skip" @click="goToNextProject">
|
<button aria-label="Skip" @click="goToNextProject">
|
||||||
<XIcon aria-hidden="true" />
|
<ExitIcon aria-hidden="true" />
|
||||||
<template v-if="futureProjects.length > 0">Skip</template>
|
<template v-if="futureProjects.length > 0">Skip</template>
|
||||||
<template v-else>Exit</template>
|
<template v-else>Exit</template>
|
||||||
</button>
|
</button>
|
||||||
@@ -335,7 +335,7 @@
|
|||||||
<div class="joined-buttons">
|
<div class="joined-buttons">
|
||||||
<ButtonStyled color="red">
|
<ButtonStyled color="red">
|
||||||
<button @click="sendMessage('rejected')">
|
<button @click="sendMessage('rejected')">
|
||||||
<XIcon aria-hidden="true" /> Reject
|
<CrossIcon aria-hidden="true" /> Reject
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled color="red">
|
<ButtonStyled color="red">
|
||||||
@@ -373,8 +373,9 @@ import {
|
|||||||
UpdatedIcon,
|
UpdatedIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
DropdownIcon,
|
DropdownIcon,
|
||||||
|
XIcon as CrossIcon,
|
||||||
EyeOffIcon,
|
EyeOffIcon,
|
||||||
XIcon,
|
ExitIcon,
|
||||||
ScaleIcon,
|
ScaleIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { ButtonStyled, MarkdownEditor, OverflowMenu, Collapsible } from "@modrinth/ui";
|
import { ButtonStyled, MarkdownEditor, OverflowMenu, Collapsible } from "@modrinth/ui";
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ButtonStyled } from "@modrinth/ui";
|
|
||||||
import { MailIcon, CheckIcon } from "@modrinth/assets";
|
|
||||||
import { ref, watchEffect } from "vue";
|
|
||||||
import { useBaseFetch } from "~/composables/fetch.js";
|
|
||||||
|
|
||||||
const auth = await useAuth();
|
|
||||||
const showSubscriptionConfirmation = ref(false);
|
|
||||||
const subscribed = ref(false);
|
|
||||||
|
|
||||||
async function checkSubscribed() {
|
|
||||||
if (auth.value?.user) {
|
|
||||||
try {
|
|
||||||
const { data } = await useBaseFetch("auth/email/subscribe", {
|
|
||||||
method: "GET",
|
|
||||||
});
|
|
||||||
subscribed.value = data?.subscribed || false;
|
|
||||||
} catch {
|
|
||||||
subscribed.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watchEffect(() => {
|
|
||||||
checkSubscribed();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function subscribe() {
|
|
||||||
try {
|
|
||||||
await useBaseFetch("auth/email/subscribe", {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
showSubscriptionConfirmation.value = true;
|
|
||||||
} catch {
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => {
|
|
||||||
showSubscriptionConfirmation.value = false;
|
|
||||||
subscribed.value = true;
|
|
||||||
}, 2500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ButtonStyled v-if="auth?.user && !subscribed" color="brand" type="outlined">
|
|
||||||
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
|
|
||||||
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
|
|
||||||
<template v-else> <CheckIcon /> Subscribed! </template>
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="vue-notification-group experimental-styles-within"
|
class="vue-notification-group experimental-styles-within"
|
||||||
:class="{
|
:class="{ 'intercom-present': isIntercomPresent }"
|
||||||
'intercom-present': isIntercomPresent,
|
|
||||||
rightwards: moveNotificationsRight,
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<transition-group name="notifs">
|
<transition-group name="notifs">
|
||||||
<div
|
<div
|
||||||
@@ -85,7 +82,6 @@ import {
|
|||||||
CopyIcon,
|
CopyIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
const notifications = useNotifications();
|
const notifications = useNotifications();
|
||||||
const { isVisible: moveNotificationsRight } = useNotificationRightwards();
|
|
||||||
|
|
||||||
const isIntercomPresent = ref(false);
|
const isIntercomPresent = ref(false);
|
||||||
|
|
||||||
@@ -164,15 +160,6 @@ function copyToClipboard(notif) {
|
|||||||
bottom: 5rem;
|
bottom: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.rightwards {
|
|
||||||
right: unset !important;
|
|
||||||
left: 1.5rem;
|
|
||||||
|
|
||||||
@media screen and (max-width: 500px) {
|
|
||||||
left: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.vue-notification-wrapper {
|
.vue-notification-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -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,116 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NewModal ref="modal" header="Moderation shortcuts" :closable="true">
|
|
||||||
<div>
|
|
||||||
<div class="keybinds-sections">
|
|
||||||
<div class="grid grid-cols-2 gap-x-12 gap-y-3">
|
|
||||||
<div
|
|
||||||
v-for="keybind in keybinds"
|
|
||||||
:key="keybind.id"
|
|
||||||
class="keybind-item flex items-center justify-between gap-4"
|
|
||||||
:class="{
|
|
||||||
'col-span-2': keybinds.length % 2 === 1 && keybinds[keybinds.length - 1] === keybind,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<span class="text-sm text-secondary">{{ keybind.description }}</span>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<kbd
|
|
||||||
v-for="(key, index) in parseKeybindDisplay(keybind.keybind)"
|
|
||||||
:key="`${keybind.id}-key-${index}`"
|
|
||||||
class="keybind-key"
|
|
||||||
>
|
|
||||||
{{ key }}
|
|
||||||
</kbd>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</NewModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from "vue";
|
|
||||||
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
|
|
||||||
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
|
|
||||||
|
|
||||||
const modal = ref<InstanceType<typeof NewModal>>();
|
|
||||||
|
|
||||||
function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
|
|
||||||
const keybinds = Array.isArray(keybind) ? keybind : [keybind];
|
|
||||||
const normalized = keybinds[0];
|
|
||||||
const def = normalizeKeybind(normalized);
|
|
||||||
|
|
||||||
const keys = [];
|
|
||||||
|
|
||||||
if (def.ctrl || def.meta) {
|
|
||||||
keys.push(isMac() ? "CMD" : "CTRL");
|
|
||||||
}
|
|
||||||
if (def.shift) keys.push("SHIFT");
|
|
||||||
if (def.alt) keys.push("ALT");
|
|
||||||
|
|
||||||
const mainKey = def.key
|
|
||||||
.replace("ArrowLeft", "←")
|
|
||||||
.replace("ArrowRight", "→")
|
|
||||||
.replace("ArrowUp", "↑")
|
|
||||||
.replace("ArrowDown", "↓")
|
|
||||||
.replace("Enter", "↵")
|
|
||||||
.replace("Space", "SPACE")
|
|
||||||
.replace("Escape", "ESC")
|
|
||||||
.toUpperCase();
|
|
||||||
|
|
||||||
keys.push(mainKey);
|
|
||||||
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMac() {
|
|
||||||
return navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function show(event?: MouseEvent) {
|
|
||||||
modal.value?.show(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hide() {
|
|
||||||
modal.value?.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
show,
|
|
||||||
hide,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.keybind-key {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 2rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
border: 1px solid var(--color-divider);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-contrast);
|
|
||||||
|
|
||||||
+ .keybind-key {
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.keybind-item {
|
|
||||||
min-height: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.keybinds-sections {
|
|
||||||
.grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,422 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
|
|
||||||
Modpack permissions ({{ Math.min(modPackData.length, currentIndex + 1) }} /
|
|
||||||
{{ modPackData.length }})
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div v-if="!modPackData">Loading data...</div>
|
|
||||||
|
|
||||||
<div v-else-if="modPackData.length === 0">
|
|
||||||
<p>All permissions obtained. You may skip this step!</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="!modPackData[currentIndex]">
|
|
||||||
<p>All permission checks complete!</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else>
|
|
||||||
<div v-if="modPackData[currentIndex].type === 'unknown'">
|
|
||||||
<p>What is the approval type of {{ modPackData[currentIndex].file_name }}?</p>
|
|
||||||
<div class="input-group">
|
|
||||||
<ButtonStyled
|
|
||||||
v-for="(option, index) in fileApprovalTypes"
|
|
||||||
:key="index"
|
|
||||||
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
|
|
||||||
@click="setStatus(currentIndex, option.id)"
|
|
||||||
>
|
|
||||||
<button>
|
|
||||||
{{ option.name }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
<div v-if="modPackData[currentIndex].status !== 'unidentified'" class="flex flex-col gap-1">
|
|
||||||
<label for="proof">
|
|
||||||
<span class="label__title">Proof</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="proof"
|
|
||||||
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).proof"
|
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="Enter proof of status..."
|
|
||||||
@input="persistAll()"
|
|
||||||
/>
|
|
||||||
<label for="link">
|
|
||||||
<span class="label__title">Link</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="link"
|
|
||||||
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).url"
|
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="Enter link of project..."
|
|
||||||
@input="persistAll()"
|
|
||||||
/>
|
|
||||||
<label for="title">
|
|
||||||
<span class="label__title">Title</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="title"
|
|
||||||
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).title"
|
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="Enter title of project..."
|
|
||||||
@input="persistAll()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="modPackData[currentIndex].type === 'flame'">
|
|
||||||
<p>
|
|
||||||
What is the approval type of {{ modPackData[currentIndex].title }} (<a
|
|
||||||
:href="modPackData[currentIndex].url"
|
|
||||||
target="_blank"
|
|
||||||
class="text-link"
|
|
||||||
>{{ modPackData[currentIndex].url }}</a
|
|
||||||
>)?
|
|
||||||
</p>
|
|
||||||
<div class="input-group">
|
|
||||||
<ButtonStyled
|
|
||||||
v-for="(option, index) in fileApprovalTypes"
|
|
||||||
:key="index"
|
|
||||||
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
|
|
||||||
@click="setStatus(currentIndex, option.id)"
|
|
||||||
>
|
|
||||||
<button>
|
|
||||||
{{ option.name }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
['unidentified', 'no', 'with-attribution'].includes(
|
|
||||||
modPackData[currentIndex].status || '',
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<p v-if="modPackData[currentIndex].status === 'unidentified'">
|
|
||||||
Does this project provide identification and permission for
|
|
||||||
<strong>{{ modPackData[currentIndex].file_name }}</strong
|
|
||||||
>?
|
|
||||||
</p>
|
|
||||||
<p v-else-if="modPackData[currentIndex].status === 'with-attribution'">
|
|
||||||
Does this project provide attribution for
|
|
||||||
<strong>{{ modPackData[currentIndex].file_name }}</strong
|
|
||||||
>?
|
|
||||||
</p>
|
|
||||||
<p v-else>
|
|
||||||
Does this project provide proof of permission for
|
|
||||||
<strong>{{ modPackData[currentIndex].file_name }}</strong
|
|
||||||
>?
|
|
||||||
</p>
|
|
||||||
<div class="input-group">
|
|
||||||
<ButtonStyled
|
|
||||||
v-for="(option, index) in filePermissionTypes"
|
|
||||||
:key="index"
|
|
||||||
:color="modPackData[currentIndex].approved === option.id ? 'brand' : 'standard'"
|
|
||||||
@click="setApproval(currentIndex, option.id)"
|
|
||||||
>
|
|
||||||
<button>
|
|
||||||
{{ option.name }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex gap-2">
|
|
||||||
<ButtonStyled>
|
|
||||||
<button :disabled="currentIndex <= 0" @click="goToPrevious">
|
|
||||||
<LeftArrowIcon aria-hidden="true" />
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-if="modPackData && currentIndex < modPackData.length" color="blue">
|
|
||||||
<button :disabled="!canGoNext" @click="goToNext">
|
|
||||||
<RightArrowIcon aria-hidden="true" />
|
|
||||||
{{ currentIndex + 1 >= modPackData.length ? "Complete" : "Next" }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { LeftArrowIcon, RightArrowIcon } from "@modrinth/assets";
|
|
||||||
import type {
|
|
||||||
ModerationJudgements,
|
|
||||||
ModerationModpackItem,
|
|
||||||
ModerationModpackResponse,
|
|
||||||
ModerationUnknownModpackItem,
|
|
||||||
ModerationFlameModpackItem,
|
|
||||||
ModerationModpackPermissionApprovalType,
|
|
||||||
ModerationPermissionType,
|
|
||||||
} from "@modrinth/utils";
|
|
||||||
import { ButtonStyled } from "@modrinth/ui";
|
|
||||||
import { ref, computed, watch, onMounted } from "vue";
|
|
||||||
import { useLocalStorage } from "@vueuse/core";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
projectId: string;
|
|
||||||
modelValue?: ModerationJudgements;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
complete: [];
|
|
||||||
"update:modelValue": [judgements: ModerationJudgements];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
|
|
||||||
`modpack-permissions-${props.projectId}`,
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serializer: {
|
|
||||||
read: (v: any) => (v ? JSON.parse(v) : null),
|
|
||||||
write: (v: any) => JSON.stringify(v),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
|
|
||||||
|
|
||||||
const modPackData = ref<ModerationModpackItem[] | null>(null);
|
|
||||||
const currentIndex = ref(0);
|
|
||||||
|
|
||||||
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
|
|
||||||
{
|
|
||||||
id: "yes",
|
|
||||||
name: "Yes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "with-attribution-and-source",
|
|
||||||
name: "With attribution and source",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "with-attribution",
|
|
||||||
name: "With attribution",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "no",
|
|
||||||
name: "No",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "permanent-no",
|
|
||||||
name: "Permanent no",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "unidentified",
|
|
||||||
name: "Unidentified",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const filePermissionTypes: ModerationPermissionType[] = [
|
|
||||||
{ id: "yes", name: "Yes" },
|
|
||||||
{ id: "no", name: "No" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function persistAll() {
|
|
||||||
persistedModPackData.value = modPackData.value;
|
|
||||||
persistedIndex.value = currentIndex.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
modPackData,
|
|
||||||
(newValue) => {
|
|
||||||
persistedModPackData.value = newValue;
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(currentIndex, (newValue) => {
|
|
||||||
persistedIndex.value = newValue;
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadPersistedData(): void {
|
|
||||||
if (persistedModPackData.value) {
|
|
||||||
modPackData.value = persistedModPackData.value;
|
|
||||||
}
|
|
||||||
currentIndex.value = persistedIndex.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearPersistedData(): void {
|
|
||||||
persistedModPackData.value = null;
|
|
||||||
persistedIndex.value = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchModPackData(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
|
|
||||||
internal: true,
|
|
||||||
})) as ModerationModpackResponse;
|
|
||||||
const sortedData: ModerationModpackItem[] = [
|
|
||||||
...Object.entries(data.unknown_files || {})
|
|
||||||
.map(
|
|
||||||
([sha1, fileName]): ModerationUnknownModpackItem => ({
|
|
||||||
sha1,
|
|
||||||
file_name: fileName,
|
|
||||||
type: "unknown",
|
|
||||||
status: null,
|
|
||||||
approved: null,
|
|
||||||
proof: "",
|
|
||||||
url: "",
|
|
||||||
title: "",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
|
|
||||||
...Object.entries(data.flame_files || {})
|
|
||||||
.map(
|
|
||||||
([sha1, info]): ModerationFlameModpackItem => ({
|
|
||||||
sha1,
|
|
||||||
file_name: info.file_name,
|
|
||||||
type: "flame",
|
|
||||||
status: null,
|
|
||||||
approved: null,
|
|
||||||
id: info.id,
|
|
||||||
title: info.title || info.file_name,
|
|
||||||
url: info.url || `https://www.curseforge.com/minecraft/mc-mods/${info.id}`,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (modPackData.value) {
|
|
||||||
const existingMap = new Map(modPackData.value.map((item) => [item.sha1, item]));
|
|
||||||
|
|
||||||
sortedData.forEach((item) => {
|
|
||||||
const existing = existingMap.get(item.sha1);
|
|
||||||
if (existing) {
|
|
||||||
Object.assign(item, {
|
|
||||||
status: existing.status,
|
|
||||||
approved: existing.approved,
|
|
||||||
...(item.type === "unknown" && {
|
|
||||||
proof: (existing as ModerationUnknownModpackItem).proof || "",
|
|
||||||
url: (existing as ModerationUnknownModpackItem).url || "",
|
|
||||||
title: (existing as ModerationUnknownModpackItem).title || "",
|
|
||||||
}),
|
|
||||||
...(item.type === "flame" && {
|
|
||||||
url: (existing as ModerationFlameModpackItem).url || item.url,
|
|
||||||
title: (existing as ModerationFlameModpackItem).title || item.title,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
modPackData.value = sortedData;
|
|
||||||
persistAll();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch modpack data:", error);
|
|
||||||
modPackData.value = [];
|
|
||||||
persistAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToPrevious(): void {
|
|
||||||
if (currentIndex.value > 0) {
|
|
||||||
currentIndex.value--;
|
|
||||||
persistAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToNext(): void {
|
|
||||||
if (modPackData.value && currentIndex.value < modPackData.value.length) {
|
|
||||||
currentIndex.value++;
|
|
||||||
|
|
||||||
if (currentIndex.value >= modPackData.value.length) {
|
|
||||||
const judgements = getJudgements();
|
|
||||||
emit("update:modelValue", judgements);
|
|
||||||
emit("complete");
|
|
||||||
clearPersistedData();
|
|
||||||
} else {
|
|
||||||
persistAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(index: number, status: ModerationModpackPermissionApprovalType["id"]): void {
|
|
||||||
if (modPackData.value && modPackData.value[index]) {
|
|
||||||
modPackData.value[index].status = status;
|
|
||||||
modPackData.value[index].approved = null;
|
|
||||||
persistAll();
|
|
||||||
emit("update:modelValue", getJudgements());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setApproval(index: number, approved: ModerationPermissionType["id"]): void {
|
|
||||||
if (modPackData.value && modPackData.value[index]) {
|
|
||||||
modPackData.value[index].approved = approved;
|
|
||||||
persistAll();
|
|
||||||
emit("update:modelValue", getJudgements());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const canGoNext = computed(() => {
|
|
||||||
if (!modPackData.value || !modPackData.value[currentIndex.value]) return false;
|
|
||||||
const current = modPackData.value[currentIndex.value];
|
|
||||||
return current.status !== null;
|
|
||||||
});
|
|
||||||
|
|
||||||
function getJudgements(): ModerationJudgements {
|
|
||||||
if (!modPackData.value) return {};
|
|
||||||
|
|
||||||
const judgements: ModerationJudgements = {};
|
|
||||||
|
|
||||||
modPackData.value.forEach((item) => {
|
|
||||||
if (item.type === "flame") {
|
|
||||||
judgements[item.sha1] = {
|
|
||||||
type: "flame",
|
|
||||||
id: item.id,
|
|
||||||
status: item.status,
|
|
||||||
link: item.url,
|
|
||||||
title: item.title,
|
|
||||||
file_name: item.file_name,
|
|
||||||
};
|
|
||||||
} else if (item.type === "unknown") {
|
|
||||||
judgements[item.sha1] = {
|
|
||||||
type: "unknown",
|
|
||||||
status: item.status,
|
|
||||||
proof: item.proof,
|
|
||||||
link: item.url,
|
|
||||||
title: item.title,
|
|
||||||
file_name: item.file_name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return judgements;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadPersistedData();
|
|
||||||
if (!modPackData.value) {
|
|
||||||
fetchModPackData();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.projectId,
|
|
||||||
() => {
|
|
||||||
clearPersistedData();
|
|
||||||
loadPersistedData();
|
|
||||||
if (!modPackData.value) {
|
|
||||||
fetchModPackData();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.input-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modpack-buttons {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
|
||||||
@@ -172,7 +172,6 @@ const flags = useFeatureFlags();
|
|||||||
|
|
||||||
.markdown-body {
|
.markdown-body {
|
||||||
grid-area: body;
|
grid-area: body;
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporter-info {
|
.reporter-info {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<NewModal ref="modModal" :header="`Editing ${type.toLocaleLowerCase()} version`">
|
<NewModal ref="modModal" :header="`Editing ${type.toLocaleLowerCase()} version`">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex min-w-full items-center gap-2 md:w-[calc(420px-5.5rem)]">
|
<div class="flex min-w-full items-center gap-2 md:w-[calc(420px-5.5rem)]">
|
||||||
<Avatar :src="modDetails?.icon_url" size="48px" :alt="`${modDetails?.name} Icon`" />
|
<UiAvatar :src="modDetails?.icon_url" size="48px" :alt="`${modDetails?.name} Icon`" />
|
||||||
<span class="truncate text-xl font-extrabold text-contrast">{{ modDetails?.name }}</span>
|
<span class="truncate text-xl font-extrabold text-contrast">{{ modDetails?.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
|
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
|
||||||
@click="
|
@click="
|
||||||
versionFilter &&
|
versionFilter &&
|
||||||
(unlockFilterAccordion.isOpen
|
(unlockFilterAccordion.isOpen
|
||||||
? unlockFilterAccordion.close()
|
? unlockFilterAccordion.close()
|
||||||
: unlockFilterAccordion.open())
|
: unlockFilterAccordion.open())
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<TagItem
|
<TagItem
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
Something went wrong trying to load versions for this {{ type.toLocaleLowerCase() }}.
|
Something went wrong trying to load versions for this {{ type.toLocaleLowerCase() }}.
|
||||||
Please try again later or contact support if the issue persists.
|
Please try again later or contact support if the issue persists.
|
||||||
</span>
|
</span>
|
||||||
<CopyCode class="!mt-2 !break-all" :text="versionsError" />
|
<LazyUiCopyCode class="!mt-2 !break-all" :text="versionsError" />
|
||||||
</div>
|
</div>
|
||||||
</Admonition>
|
</Admonition>
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ import {
|
|||||||
GameIcon,
|
GameIcon,
|
||||||
ExternalIcon,
|
ExternalIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { Admonition, Avatar, ButtonStyled, CopyCode, NewModal } from "@modrinth/ui";
|
import { Admonition, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||||
import TagItem from "@modrinth/ui/src/components/base/TagItem.vue";
|
import TagItem from "@modrinth/ui/src/components/base/TagItem.vue";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { formatCategory, formatVersionsForDisplay, type Mod, type Version } from "@modrinth/utils";
|
import { formatCategory, formatVersionsForDisplay, type Mod, type Version } from "@modrinth/utils";
|
||||||
|
|||||||
@@ -1,176 +1,124 @@
|
|||||||
<template>
|
<template>
|
||||||
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
|
<NewModal ref="mrpackModal" header="Uploading mrpack" @hide="onHide" @show="onShow">
|
||||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||||
<Transition
|
<p
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
v-if="isMrpackModalSecondPhase"
|
||||||
enter-from-class="opacity-0 max-h-0"
|
:style="{
|
||||||
enter-to-class="opacity-100 max-h-20"
|
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
|
||||||
leave-active-class="transition-all duration-200 ease-in"
|
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
|
||||||
leave-from-class="opacity-100 max-h-20"
|
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
|
||||||
leave-to-class="opacity-0 max-h-0"
|
}"
|
||||||
>
|
>
|
||||||
<div v-if="isLoading" class="w-full">
|
This will reinstall your server and erase all data. You may want to back up your server
|
||||||
<div class="mb-2 flex justify-between text-sm">
|
before proceeding. Are you sure you want to continue?
|
||||||
<Transition name="phrase-fade" mode="out-in">
|
</p>
|
||||||
<span :key="currentPhrase" class="text-lg font-medium text-contrast">{{
|
<div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
|
||||||
currentPhrase
|
<div class="mx-auto flex flex-row items-center gap-4">
|
||||||
}}</span>
|
<div
|
||||||
</Transition>
|
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||||
<div class="flex flex-col items-end">
|
|
||||||
<span class="text-secondary">{{ Math.round(uploadProgress) }}%</span>
|
|
||||||
<span class="text-xs text-secondary"
|
|
||||||
>{{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="h-2 w-full rounded-full bg-divider">
|
|
||||||
<div
|
|
||||||
class="h-2 animate-pulse rounded-full bg-brand transition-all duration-300 ease-out"
|
|
||||||
:style="{ width: `${uploadProgress}%` }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition-all duration-300 ease-out"
|
|
||||||
enter-from-class="opacity-0 max-h-0"
|
|
||||||
enter-to-class="opacity-100 max-h-20"
|
|
||||||
leave-active-class="transition-all duration-200 ease-in"
|
|
||||||
leave-from-class="opacity-100 max-h-20"
|
|
||||||
leave-to-class="opacity-0 max-h-0"
|
|
||||||
>
|
|
||||||
<div v-if="!isLoading" class="flex flex-col gap-4">
|
|
||||||
<p
|
|
||||||
v-if="isMrpackModalSecondPhase"
|
|
||||||
:style="{
|
|
||||||
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
|
|
||||||
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
|
|
||||||
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
This will reinstall your server and erase all data. You may want to back up your server
|
<UploadIcon class="size-10" />
|
||||||
before proceeding. Are you sure you want to continue?
|
|
||||||
</p>
|
|
||||||
<div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
|
|
||||||
<div class="mx-auto flex flex-row items-center gap-4">
|
|
||||||
<div
|
|
||||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
|
|
||||||
>
|
|
||||||
<UploadIcon class="size-10" />
|
|
||||||
</div>
|
|
||||||
<ArrowBigRightDashIcon class="size-10" />
|
|
||||||
<div
|
|
||||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
|
|
||||||
>
|
|
||||||
<ServerIcon class="size-10" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
|
||||||
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".mrpack"
|
|
||||||
class=""
|
|
||||||
:disabled="isLoading"
|
|
||||||
@change="uploadMrpack"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
|
||||||
<div class="flex w-full flex-row items-center justify-between">
|
|
||||||
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
|
|
||||||
Erase all data
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="hard-reset"
|
|
||||||
v-model="hardReset"
|
|
||||||
class="switch stylized-toggle shrink-0"
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Removes all data on your server, including your worlds, mods, and configuration
|
|
||||||
files, then reinstalls it with the selected version.
|
|
||||||
</div>
|
|
||||||
<div class="font-bold">
|
|
||||||
This does not affect your backups, which are stored off-site.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex justify-start gap-4">
|
<svg
|
||||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<button
|
width="24"
|
||||||
v-tooltip="backupInProgress ? backupInProgress.tooltip : undefined"
|
height="24"
|
||||||
:disabled="canInstall || !!backupInProgress"
|
viewBox="0 0 24 24"
|
||||||
@click="handleReinstall"
|
fill="none"
|
||||||
>
|
stroke="currentColor"
|
||||||
<RightArrowIcon />
|
stroke-width="2"
|
||||||
{{
|
stroke-linecap="round"
|
||||||
isMrpackModalSecondPhase
|
stroke-linejoin="round"
|
||||||
? "Erase and install"
|
class="size-10"
|
||||||
: loadingServerCheck
|
>
|
||||||
? "Loading..."
|
<path d="M5 9v6" />
|
||||||
: isDangerous
|
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
|
||||||
? "Erase and install"
|
</svg>
|
||||||
: "Install"
|
<div
|
||||||
}}
|
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
|
||||||
</button>
|
>
|
||||||
</ButtonStyled>
|
<ServerIcon class="size-10" />
|
||||||
<ButtonStyled>
|
|
||||||
<button
|
|
||||||
:disabled="isLoading"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
if (isMrpackModalSecondPhase) {
|
|
||||||
isMrpackModalSecondPhase = false;
|
|
||||||
} else {
|
|
||||||
hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<XIcon />
|
|
||||||
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||||
|
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".mrpack"
|
||||||
|
class=""
|
||||||
|
:disabled="isLoading"
|
||||||
|
@change="uploadMrpack"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||||
|
<div class="flex w-full flex-row items-center justify-between">
|
||||||
|
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
|
||||||
|
Erase all data
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="hard-reset"
|
||||||
|
v-model="hardReset"
|
||||||
|
class="switch stylized-toggle shrink-0"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Removes all data on your server, including your worlds, mods, and configuration files,
|
||||||
|
then reinstalls it with the selected version.
|
||||||
|
</div>
|
||||||
|
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-start gap-4">
|
||||||
|
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||||
|
<button
|
||||||
|
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||||
|
:disabled="canInstall || backupInProgress"
|
||||||
|
@click="handleReinstall"
|
||||||
|
>
|
||||||
|
<RightArrowIcon />
|
||||||
|
{{
|
||||||
|
isMrpackModalSecondPhase
|
||||||
|
? "Erase and install"
|
||||||
|
: loadingServerCheck
|
||||||
|
? "Loading..."
|
||||||
|
: isDangerous
|
||||||
|
? "Erase and install"
|
||||||
|
: "Install"
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
if (isMrpackModalSecondPhase) {
|
||||||
|
isMrpackModalSecondPhase = false;
|
||||||
|
} else {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NewModal>
|
</NewModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||||
import {
|
import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
|
||||||
UploadIcon,
|
import { ModrinthServersFetchError } from "@modrinth/utils";
|
||||||
RightArrowIcon,
|
|
||||||
XIcon,
|
|
||||||
ServerIcon,
|
|
||||||
ArrowBigRightDashIcon,
|
|
||||||
} from "@modrinth/assets";
|
|
||||||
import { formatBytes, ModrinthServersFetchError } from "@modrinth/utils";
|
|
||||||
import { onMounted, onUnmounted } from "vue";
|
|
||||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||||
import type { ModrinthServer } from "~/composables/servers/modrinth-servers";
|
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||||
|
|
||||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
||||||
if (isLoading.value) {
|
|
||||||
event.preventDefault();
|
|
||||||
return "Upload in progress. Are you sure you want to leave?";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
server: ModrinthServer;
|
server: ModrinthServer;
|
||||||
@@ -187,49 +135,6 @@ const hardReset = ref(false);
|
|||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const loadingServerCheck = ref(false);
|
const loadingServerCheck = ref(false);
|
||||||
const mrpackFile = ref<File | null>(null);
|
const mrpackFile = ref<File | null>(null);
|
||||||
const uploadProgress = ref(0);
|
|
||||||
const uploadedBytes = ref(0);
|
|
||||||
const totalBytes = ref(0);
|
|
||||||
|
|
||||||
const uploadPhrases = [
|
|
||||||
"Removing Herobrine...",
|
|
||||||
"Feeding parrots...",
|
|
||||||
"Teaching villagers new trades...",
|
|
||||||
"Convincing creepers to be friendly...",
|
|
||||||
"Polishing diamonds...",
|
|
||||||
"Training wolves to fetch...",
|
|
||||||
"Building pixel art...",
|
|
||||||
"Explaining redstone to beginners...",
|
|
||||||
"Collecting all the cats...",
|
|
||||||
"Negotiating with endermen...",
|
|
||||||
"Planting suspicious stew ingredients...",
|
|
||||||
"Calibrating TNT blast radius...",
|
|
||||||
"Teaching chickens to fly...",
|
|
||||||
"Sorting inventory alphabetically...",
|
|
||||||
"Convincing iron golems to smile...",
|
|
||||||
];
|
|
||||||
|
|
||||||
const currentPhrase = ref("Uploading...");
|
|
||||||
let phraseInterval: NodeJS.Timeout | null = null;
|
|
||||||
const usedPhrases = ref(new Set<number>());
|
|
||||||
|
|
||||||
const getNextPhrase = () => {
|
|
||||||
if (usedPhrases.value.size >= uploadPhrases.length) {
|
|
||||||
const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value);
|
|
||||||
usedPhrases.value.clear();
|
|
||||||
if (currentPhraseIndex !== -1) {
|
|
||||||
usedPhrases.value.add(currentPhraseIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const availableIndices = uploadPhrases
|
|
||||||
.map((_, index) => index)
|
|
||||||
.filter((index) => !usedPhrases.value.has(index));
|
|
||||||
|
|
||||||
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)];
|
|
||||||
usedPhrases.value.add(randomIndex);
|
|
||||||
|
|
||||||
return uploadPhrases[randomIndex];
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDangerous = computed(() => hardReset.value);
|
const isDangerous = computed(() => hardReset.value);
|
||||||
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
|
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
|
||||||
@@ -248,46 +153,18 @@ const handleReinstall = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mrpackFile.value) {
|
|
||||||
addNotification({
|
|
||||||
group: "server",
|
|
||||||
title: "No file selected",
|
|
||||||
text: "Choose a .mrpack file before installing.",
|
|
||||||
type: "error",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
uploadProgress.value = 0;
|
|
||||||
uploadProgress.value = 0;
|
|
||||||
uploadedBytes.value = 0;
|
|
||||||
totalBytes.value = mrpackFile.value.size;
|
|
||||||
|
|
||||||
currentPhrase.value = getNextPhrase();
|
|
||||||
phraseInterval = setInterval(() => {
|
|
||||||
currentPhrase.value = getNextPhrase();
|
|
||||||
}, 4500);
|
|
||||||
|
|
||||||
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
|
|
||||||
mrpackFile.value,
|
|
||||||
hardReset.value,
|
|
||||||
);
|
|
||||||
|
|
||||||
onProgress(({ loaded, total, progress }) => {
|
|
||||||
uploadProgress.value = progress;
|
|
||||||
uploadedBytes.value = loaded;
|
|
||||||
totalBytes.value = total;
|
|
||||||
|
|
||||||
if (phraseInterval && progress >= 100) {
|
|
||||||
clearInterval(phraseInterval);
|
|
||||||
phraseInterval = null;
|
|
||||||
currentPhrase.value = "Installing modpack...";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await promise;
|
if (!mrpackFile.value) {
|
||||||
|
throw new Error("No mrpack file selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mrpack = new File([mrpackFile.value], mrpackFile.value.name, {
|
||||||
|
type: mrpackFile.value.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
await props.server.general?.reinstallFromMrpack(mrpack, hardReset.value);
|
||||||
|
|
||||||
emit("reinstall", {
|
emit("reinstall", {
|
||||||
loader: "mrpack",
|
loader: "mrpack",
|
||||||
@@ -299,44 +176,36 @@ const handleReinstall = async () => {
|
|||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
hide();
|
hide();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
|
if (error instanceof ModrinthServersFetchError && error?.statusCode === 429) {
|
||||||
addNotification({
|
addNotification({
|
||||||
group: "server",
|
group: "server",
|
||||||
title: "Cannot upload and install modpack to server",
|
title: "Cannot reinstall server",
|
||||||
text: "You are being rate limited. Please try again later.",
|
text: "You are being rate limited. Please try again later.",
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
addNotification({
|
addNotification({
|
||||||
group: "server",
|
group: "server",
|
||||||
title: "Modpack upload and install failed",
|
title: "Reinstall Failed",
|
||||||
text: "An unexpected error occurred while uploading/installing. Please try again later.",
|
text: "An unexpected error occurred while reinstalling. Please try again later.",
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
if (phraseInterval) {
|
|
||||||
clearInterval(phraseInterval);
|
|
||||||
phraseInterval = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onShow = () => {
|
const onShow = () => {
|
||||||
hardReset.value = false;
|
hardReset.value = false;
|
||||||
isMrpackModalSecondPhase.value = false;
|
isMrpackModalSecondPhase.value = false;
|
||||||
loadingServerCheck.value = false;
|
loadingServerCheck.value = false;
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
mrpackFile.value = null;
|
mrpackFile.value = null;
|
||||||
uploadProgress.value = 0;
|
};
|
||||||
uploadedBytes.value = 0;
|
|
||||||
totalBytes.value = 0;
|
const onHide = () => {
|
||||||
currentPhrase.value = "Uploading...";
|
onShow();
|
||||||
usedPhrases.value.clear();
|
|
||||||
if (phraseInterval) {
|
|
||||||
clearInterval(phraseInterval);
|
|
||||||
phraseInterval = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const show = () => mrpackModal.value?.show();
|
const show = () => mrpackModal.value?.show();
|
||||||
@@ -349,14 +218,4 @@ defineExpose({ show, hide });
|
|||||||
.stylized-toggle:checked::after {
|
.stylized-toggle:checked::after {
|
||||||
background: var(--color-accent-contrast) !important;
|
background: var(--color-accent-contrast) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.phrase-fade-enter-active,
|
|
||||||
.phrase-fade-leave-active {
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phrase-fade-enter-from,
|
|
||||||
.phrase-fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
v-if="projectData?.title"
|
v-if="projectData?.title"
|
||||||
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
|
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
|
||||||
>
|
>
|
||||||
<Avatar
|
<UiAvatar
|
||||||
:src="iconUrl"
|
:src="iconUrl"
|
||||||
no-shadow
|
no-shadow
|
||||||
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
|
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
|
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
|
||||||
update your billing information or contact Modrinth Support for more information.
|
update your billing information or contact Modrinth Support for more information.
|
||||||
</div>
|
</div>
|
||||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
<UiCopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
@@ -83,7 +83,6 @@
|
|||||||
import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets";
|
import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets";
|
||||||
import type { Project, Server } from "@modrinth/utils";
|
import type { Project, Server } from "@modrinth/utils";
|
||||||
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
|
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
|
||||||
import { Avatar, CopyCode } from "@modrinth/ui";
|
|
||||||
|
|
||||||
const props = defineProps<Partial<Server>>();
|
const props = defineProps<Partial<Server>>();
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,9 @@
|
|||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nuxt-link
|
|
||||||
|
<component
|
||||||
|
:is="loading ? 'div' : 'NuxtLink'"
|
||||||
:to="loading ? undefined : `/servers/manage/${serverId}/files`"
|
:to="loading ? undefined : `/servers/manage/${serverId}/files`"
|
||||||
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
||||||
:class="loading ? '' : 'transition-transform duration-100 hover:scale-105 active:scale-100'"
|
:class="loading ? '' : 'transition-transform duration-100 hover:scale-105 active:scale-100'"
|
||||||
@@ -62,17 +64,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
|
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
|
||||||
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
||||||
</nuxt-link>
|
</component>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, shallowRef } from "vue";
|
import { ref, computed, shallowRef } from "vue";
|
||||||
import { FolderOpenIcon, CpuIcon, DatabaseIcon, IssuesIcon } from "@modrinth/assets";
|
import { FolderOpenIcon, CPUIcon, DatabaseIcon, IssuesIcon } from "@modrinth/assets";
|
||||||
import { useStorage } from "@vueuse/core";
|
import { useStorage } from "@vueuse/core";
|
||||||
import type { Stats } from "@modrinth/utils";
|
import type { Stats } from "@modrinth/utils";
|
||||||
|
|
||||||
const flags = useFeatureFlags();
|
|
||||||
const route = useNativeRoute();
|
const route = useNativeRoute();
|
||||||
const serverId = route.params.id;
|
const serverId = route.params.id;
|
||||||
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
|
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
|
||||||
@@ -126,7 +127,7 @@ const metrics = computed(() => {
|
|||||||
title: "CPU usage",
|
title: "CPU usage",
|
||||||
value: "0.00%",
|
value: "0.00%",
|
||||||
max: "100%",
|
max: "100%",
|
||||||
icon: CpuIcon,
|
icon: CPUIcon,
|
||||||
data: cpuData.value,
|
data: cpuData.value,
|
||||||
showGraph: false,
|
showGraph: false,
|
||||||
warning: null,
|
warning: null,
|
||||||
@@ -157,21 +158,17 @@ const metrics = computed(() => {
|
|||||||
title: "CPU usage",
|
title: "CPU usage",
|
||||||
value: `${cpuPercent.toFixed(2)}%`,
|
value: `${cpuPercent.toFixed(2)}%`,
|
||||||
max: "100%",
|
max: "100%",
|
||||||
icon: CpuIcon,
|
icon: CPUIcon,
|
||||||
data: cpuData.value,
|
data: cpuData.value,
|
||||||
showGraph: true,
|
showGraph: true,
|
||||||
warning: cpuPercent >= 90 ? "CPU usage is very high" : null,
|
warning: cpuPercent >= 90 ? "CPU usage is very high" : null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Memory usage",
|
title: "Memory usage",
|
||||||
value:
|
value: userPreferences.value.ramAsNumber
|
||||||
userPreferences.value.ramAsNumber || flags.developerMode
|
? formatBytes(stats.value.ram_usage_bytes)
|
||||||
? formatBytes(stats.value.ram_usage_bytes)
|
: `${ramPercent.toFixed(2)}%`,
|
||||||
: `${ramPercent.toFixed(2)}%`,
|
max: userPreferences.value.ramAsNumber ? formatBytes(stats.value.ram_total_bytes) : "100%",
|
||||||
max:
|
|
||||||
userPreferences.value.ramAsNumber || flags.developerMode
|
|
||||||
? formatBytes(stats.value.ram_total_bytes)
|
|
||||||
: "100%",
|
|
||||||
icon: DatabaseIcon,
|
icon: DatabaseIcon,
|
||||||
data: ramData.value,
|
data: ramData.value,
|
||||||
showGraph: true,
|
showGraph: true,
|
||||||
|
|||||||
@@ -1,26 +1,18 @@
|
|||||||
const formatters = new WeakMap<object, Intl.NumberFormat>();
|
import { createFormatter, type Formatter } from "@vintl/compact-number";
|
||||||
|
import type { IntlController } from "@vintl/vintl/controller";
|
||||||
|
|
||||||
export function useCompactNumber(truncate = false, fractionDigits = 2, locale?: string) {
|
const formatters = new WeakMap<IntlController<any>, Formatter>();
|
||||||
const context = {};
|
|
||||||
|
|
||||||
let formatter = formatters.get(context);
|
export function useCompactNumber(): Formatter {
|
||||||
|
const vintl = useVIntl();
|
||||||
|
|
||||||
if (!formatter) {
|
let formatter = formatters.get(vintl);
|
||||||
formatter = new Intl.NumberFormat(locale, {
|
|
||||||
notation: "compact",
|
if (formatter == null) {
|
||||||
maximumFractionDigits: fractionDigits,
|
const formatterRef = computed(() => createFormatter(vintl.intl));
|
||||||
});
|
formatter = (value, options) => formatterRef.value(value, options);
|
||||||
formatters.set(context, formatter);
|
formatters.set(vintl, formatter);
|
||||||
}
|
}
|
||||||
|
|
||||||
function format(value: number): string {
|
return formatter;
|
||||||
let formattedValue = value;
|
|
||||||
if (truncate) {
|
|
||||||
const scale = Math.pow(10, fractionDigits);
|
|
||||||
formattedValue = Math.floor(value * scale) / scale;
|
|
||||||
}
|
|
||||||
return formatter!.format(formattedValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
return format;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
|||||||
projectBackground: false,
|
projectBackground: false,
|
||||||
searchBackground: false,
|
searchBackground: false,
|
||||||
advancedDebugInfo: false,
|
advancedDebugInfo: false,
|
||||||
showProjectPageDownloadModalServersPromo: false,
|
showProjectPageDownloadModalServersPromo: true,
|
||||||
showProjectPageCreateServersTooltip: true,
|
showProjectPageCreateServersTooltip: true,
|
||||||
showProjectPageQuickServerButton: false,
|
showProjectPageQuickServerButton: false,
|
||||||
// advancedRendering: true,
|
// advancedRendering: true,
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export class ModrinthServer {
|
|||||||
try {
|
try {
|
||||||
const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
|
const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
|
||||||
override: auth,
|
override: auth,
|
||||||
retry: 1, // Reduce retries for optional resources
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fileData instanceof Blob && import.meta.client) {
|
if (fileData instanceof Blob && import.meta.client) {
|
||||||
@@ -124,14 +124,8 @@ export class ModrinthServer {
|
|||||||
return dataURL;
|
return dataURL;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ModrinthServerError) {
|
if (error instanceof ModrinthServerError && error.statusCode === 404) {
|
||||||
if (error.statusCode && error.statusCode >= 500) {
|
if (iconUrl) {
|
||||||
console.debug("Service unavailable, skipping icon processing");
|
|
||||||
sharedImage.value = undefined;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error.statusCode === 404 && iconUrl) {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(iconUrl);
|
const response = await fetch(iconUrl);
|
||||||
if (!response.ok) throw new Error("Failed to fetch icon");
|
if (!response.ok) throw new Error("Failed to fetch icon");
|
||||||
@@ -193,44 +187,6 @@ export class ModrinthServer {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async testNodeReachability(): Promise<boolean> {
|
|
||||||
if (!this.general?.node?.instance) {
|
|
||||||
console.warn("No node instance available for ping test");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsUrl = `wss://${this.general.node.instance}/pingtest`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await new Promise((resolve) => {
|
|
||||||
const socket = new WebSocket(wsUrl);
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
socket.close();
|
|
||||||
resolve(false);
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
socket.onopen = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
socket.send(performance.now().toString());
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onmessage = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
socket.close();
|
|
||||||
resolve(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onerror = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(false);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to ping node ${wsUrl}:`, error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async refresh(
|
async refresh(
|
||||||
modules: ModuleName[] = [],
|
modules: ModuleName[] = [],
|
||||||
options?: {
|
options?: {
|
||||||
@@ -244,8 +200,6 @@ export class ModrinthServer {
|
|||||||
: (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]);
|
: (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]);
|
||||||
|
|
||||||
for (const module of modulesToRefresh) {
|
for (const module of modulesToRefresh) {
|
||||||
this.errors[module] = undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (module) {
|
switch (module) {
|
||||||
case "general": {
|
case "general": {
|
||||||
@@ -296,7 +250,7 @@ export class ModrinthServer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.statusCode && error.statusCode >= 500) {
|
if (error.statusCode === 503) {
|
||||||
console.debug(`Temporary ${module} unavailable:`, error.message);
|
console.debug(`Temporary ${module} unavailable:`, error.message);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,49 +22,26 @@ export class FSModule extends ServerModule {
|
|||||||
this.opsQueuedForModification = [];
|
this.opsQueuedForModification = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async retryWithAuth<T>(
|
private async retryWithAuth<T>(requestFn: () => Promise<T>): Promise<T> {
|
||||||
requestFn: () => Promise<T>,
|
|
||||||
ignoreFailure: boolean = false,
|
|
||||||
): Promise<T> {
|
|
||||||
try {
|
try {
|
||||||
return await requestFn();
|
return await requestFn();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ModrinthServerError && error.statusCode === 401) {
|
if (error instanceof ModrinthServerError && error.statusCode === 401) {
|
||||||
console.debug("Auth failed, refreshing JWT and retrying");
|
|
||||||
await this.fetch(); // Refresh auth
|
await this.fetch(); // Refresh auth
|
||||||
return await requestFn();
|
return await requestFn();
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = await this.server.testNodeReachability();
|
|
||||||
if (!available && !ignoreFailure) {
|
|
||||||
this.server.moduleErrors.general = {
|
|
||||||
error: new ModrinthServerError(
|
|
||||||
"Unable to reach node. FS operation failed and subsequent ping test failed.",
|
|
||||||
500,
|
|
||||||
error as Error,
|
|
||||||
"fs",
|
|
||||||
),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
listDirContents(
|
listDirContents(path: string, page: number, pageSize: number): Promise<DirectoryResponse> {
|
||||||
path: string,
|
|
||||||
page: number,
|
|
||||||
pageSize: number,
|
|
||||||
ignoreFailure: boolean = false,
|
|
||||||
): Promise<DirectoryResponse> {
|
|
||||||
return this.retryWithAuth(async () => {
|
return this.retryWithAuth(async () => {
|
||||||
const encodedPath = encodeURIComponent(path);
|
const encodedPath = encodeURIComponent(path);
|
||||||
return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
|
return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
|
||||||
override: this.auth,
|
override: this.auth,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
}, ignoreFailure);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createFileOrFolder(path: string, type: "file" | "directory"): Promise<void> {
|
createFileOrFolder(path: string, type: "file" | "directory"): Promise<void> {
|
||||||
@@ -173,7 +150,7 @@ export class FSModule extends ServerModule {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadFile(path: string, raw: boolean = false, ignoreFailure: boolean = false): Promise<any> {
|
downloadFile(path: string, raw?: boolean): Promise<any> {
|
||||||
return this.retryWithAuth(async () => {
|
return this.retryWithAuth(async () => {
|
||||||
const encodedPath = encodeURIComponent(path);
|
const encodedPath = encodeURIComponent(path);
|
||||||
const fileData = await useServersFetch(`/download?path=${encodedPath}`, {
|
const fileData = await useServersFetch(`/download?path=${encodedPath}`, {
|
||||||
@@ -184,7 +161,7 @@ export class FSModule extends ServerModule {
|
|||||||
return raw ? fileData : await fileData.text();
|
return raw ? fileData : await fileData.text();
|
||||||
}
|
}
|
||||||
return fileData;
|
return fileData;
|
||||||
}, ignoreFailure);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
extractFile(
|
extractFile(
|
||||||
|
|||||||
@@ -46,18 +46,13 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
|||||||
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined;
|
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const motd = await this.getMotd();
|
||||||
const motd = await this.getMotd();
|
if (motd === "A Minecraft Server") {
|
||||||
if (motd === "A Minecraft Server") {
|
await this.setMotd(
|
||||||
await this.setMotd(
|
`§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`,
|
||||||
`§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
data.motd = motd;
|
|
||||||
} catch {
|
|
||||||
console.error("[Modrinth Servers] [General] Failed to fetch MOTD.");
|
|
||||||
data.motd = undefined;
|
|
||||||
}
|
}
|
||||||
|
data.motd = motd;
|
||||||
|
|
||||||
// Copy data to this module
|
// Copy data to this module
|
||||||
Object.assign(this, data);
|
Object.assign(this, data);
|
||||||
@@ -103,67 +98,28 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reinstallFromMrpack(
|
async reinstallFromMrpack(mrpack: File, hardReset: boolean = false): Promise<void> {
|
||||||
mrpack: File,
|
|
||||||
hardReset: boolean = false,
|
|
||||||
): {
|
|
||||||
promise: Promise<void>;
|
|
||||||
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => void;
|
|
||||||
} {
|
|
||||||
const hardResetParam = hardReset ? "true" : "false";
|
const hardResetParam = hardReset ? "true" : "false";
|
||||||
|
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/reinstallFromMrpack`);
|
||||||
|
|
||||||
const progressSubject = new EventTarget();
|
const formData = new FormData();
|
||||||
|
formData.append("file", mrpack);
|
||||||
|
|
||||||
const uploadPromise = (async () => {
|
const response = await fetch(
|
||||||
try {
|
`https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`,
|
||||||
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/reinstallFromMrpack`);
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${auth.token}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
signal: AbortSignal.timeout(30 * 60 * 1000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
if (!response.ok) {
|
||||||
const xhr = new XMLHttpRequest();
|
throw new Error(`[pyroservers] native fetch err status: ${response.status}`);
|
||||||
|
}
|
||||||
xhr.upload.addEventListener("progress", (e) => {
|
|
||||||
if (e.lengthComputable) {
|
|
||||||
progressSubject.dispatchEvent(
|
|
||||||
new CustomEvent("progress", {
|
|
||||||
detail: {
|
|
||||||
loaded: e.loaded,
|
|
||||||
total: e.total,
|
|
||||||
progress: (e.loaded / e.total) * 100,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.onload = () =>
|
|
||||||
xhr.status >= 200 && xhr.status < 300
|
|
||||||
? resolve()
|
|
||||||
: reject(new Error(`[pyroservers] XHR error status: ${xhr.status}`));
|
|
||||||
|
|
||||||
xhr.onerror = () => reject(new Error("[pyroservers] .mrpack upload failed"));
|
|
||||||
xhr.onabort = () => reject(new Error("[pyroservers] .mrpack upload cancelled"));
|
|
||||||
xhr.ontimeout = () => reject(new Error("[pyroservers] .mrpack upload timed out"));
|
|
||||||
xhr.timeout = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
xhr.open("POST", `https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`);
|
|
||||||
xhr.setRequestHeader("Authorization", `Bearer ${auth.token}`);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", mrpack);
|
|
||||||
xhr.send(formData);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error reinstalling from mrpack:", err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return {
|
|
||||||
promise: uploadPromise,
|
|
||||||
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) =>
|
|
||||||
progressSubject.addEventListener("progress", ((e: CustomEvent) =>
|
|
||||||
cb(e.detail)) as EventListener),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async suspend(status: boolean): Promise<void> {
|
async suspend(status: boolean): Promise<void> {
|
||||||
@@ -183,7 +139,7 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
|||||||
|
|
||||||
async getMotd(): Promise<string | undefined> {
|
async getMotd(): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
const props = await this.server.fs.downloadFile("/server.properties", false, true);
|
const props = await this.server.fs.downloadFile("/server.properties");
|
||||||
if (props) {
|
if (props) {
|
||||||
const lines = props.split("\n");
|
const lines = props.split("\n");
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
|||||||
@@ -42,23 +42,6 @@ export async function useServersFetch<T>(
|
|||||||
retry = method === "GET" ? 3 : 0,
|
retry = method === "GET" ? 3 : 0,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const circuitBreakerKey = `${module || "default"}_${path}`;
|
|
||||||
const failureCount = useState<number>(`fetch_failures_${circuitBreakerKey}`, () => 0);
|
|
||||||
const lastFailureTime = useState<number>(`last_failure_${circuitBreakerKey}`, () => 0);
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
|
|
||||||
const error = new ModrinthServersFetchError(
|
|
||||||
"[Modrinth Servers] Circuit breaker open - too many recent failures",
|
|
||||||
503,
|
|
||||||
);
|
|
||||||
throw new ModrinthServerError("Service temporarily unavailable", 503, error, module);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (now - lastFailureTime.value > 30000) {
|
|
||||||
failureCount.value = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
|
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
|
||||||
/\/$/,
|
/\/$/,
|
||||||
"",
|
"",
|
||||||
@@ -86,7 +69,6 @@ export async function useServersFetch<T>(
|
|||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"User-Agent": "Modrinth/1.0 (https://modrinth.com)",
|
"User-Agent": "Modrinth/1.0 (https://modrinth.com)",
|
||||||
"X-Archon-Request": "true",
|
|
||||||
Vary: "Accept, Origin",
|
Vary: "Accept, Origin",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,12 +94,10 @@ export async function useServersFetch<T>(
|
|||||||
const response = await $fetch<T>(fullUrl, {
|
const response = await $fetch<T>(fullUrl, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body:
|
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
|
||||||
body && contentType === "application/json" ? JSON.stringify(body) : (body ?? undefined),
|
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
failureCount.value = 0;
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error as Error;
|
lastError = error as Error;
|
||||||
@@ -127,11 +107,6 @@ export async function useServersFetch<T>(
|
|||||||
const statusCode = error.response?.status;
|
const statusCode = error.response?.status;
|
||||||
const statusText = error.response?.statusText || "Unknown error";
|
const statusText = error.response?.statusText || "Unknown error";
|
||||||
|
|
||||||
if (statusCode && statusCode >= 500) {
|
|
||||||
failureCount.value++;
|
|
||||||
lastFailureTime.value = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
let v1Error: V1ErrorInfo | undefined;
|
let v1Error: V1ErrorInfo | undefined;
|
||||||
if (error.data?.error && error.data?.description) {
|
if (error.data?.error && error.data?.description) {
|
||||||
v1Error = {
|
v1Error = {
|
||||||
@@ -159,11 +134,9 @@ export async function useServersFetch<T>(
|
|||||||
? errorMessages[statusCode]
|
? errorMessages[statusCode]
|
||||||
: `HTTP Error: ${statusCode || "unknown"} ${statusText}`;
|
: `HTTP Error: ${statusCode || "unknown"} ${statusText}`;
|
||||||
|
|
||||||
const isRetryable = statusCode ? [408, 429].includes(statusCode) : false;
|
const isRetryable = statusCode ? [408, 429, 500, 502, 504].includes(statusCode) : true;
|
||||||
const is5xxRetryable =
|
|
||||||
statusCode && statusCode >= 500 && statusCode < 600 && method === "GET" && attempts === 1;
|
|
||||||
|
|
||||||
if (!(isRetryable || is5xxRetryable) || attempts >= maxAttempts) {
|
if (!isRetryable || attempts >= maxAttempts) {
|
||||||
console.error("Fetch error:", error);
|
console.error("Fetch error:", error);
|
||||||
|
|
||||||
const fetchError = new ModrinthServersFetchError(
|
const fetchError = new ModrinthServersFetchError(
|
||||||
@@ -174,8 +147,7 @@ export async function useServersFetch<T>(
|
|||||||
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error);
|
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000;
|
const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000);
|
||||||
const delay = Math.min(baseDelay * Math.pow(2, attempts - 1) + Math.random() * 1000, 15000);
|
|
||||||
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`);
|
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`);
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
export const useNotificationRightwards = () => {
|
|
||||||
const isVisible = useState("moderation-checklist-notifications", () => false);
|
|
||||||
|
|
||||||
const setVisible = (visible: boolean) => {
|
|
||||||
isVisible.value = visible;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
isVisible: readonly(isVisible),
|
|
||||||
setVisible,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<nuxt-link to="/resourcepacks">
|
<nuxt-link to="/resourcepacks">
|
||||||
<PaintbrushIcon aria-hidden="true" /> Resource Packs
|
<PaintBrushIcon aria-hidden="true" /> Resource Packs
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
v-if="route.name === 'search-mods' || route.path.startsWith('/mod/')"
|
v-if="route.name === 'search-mods' || route.path.startsWith('/mod/')"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<PaintbrushIcon
|
<PaintBrushIcon
|
||||||
v-else-if="
|
v-else-if="
|
||||||
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
|
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
|
||||||
"
|
"
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
|
|
||||||
<template #mods> <BoxIcon aria-hidden="true" /> Mods </template>
|
<template #mods> <BoxIcon aria-hidden="true" /> Mods </template>
|
||||||
<template #resourcepacks>
|
<template #resourcepacks>
|
||||||
<PaintbrushIcon aria-hidden="true" /> Resource Packs
|
<PaintBrushIcon aria-hidden="true" /> Resource Packs
|
||||||
</template>
|
</template>
|
||||||
<template #datapacks> <BracesIcon aria-hidden="true" /> Data Packs </template>
|
<template #datapacks> <BracesIcon aria-hidden="true" /> Data Packs </template>
|
||||||
<template #plugins> <PlugIcon aria-hidden="true" /> Plugins </template>
|
<template #plugins> <PlugIcon aria-hidden="true" /> Plugins </template>
|
||||||
@@ -696,13 +696,14 @@ import {
|
|||||||
CurrencyIcon,
|
CurrencyIcon,
|
||||||
BracesIcon,
|
BracesIcon,
|
||||||
GlassesIcon,
|
GlassesIcon,
|
||||||
PaintbrushIcon,
|
PaintBrushIcon,
|
||||||
PackageOpenIcon,
|
PackageOpenIcon,
|
||||||
DiscordIcon,
|
DiscordIcon,
|
||||||
BlueskyIcon,
|
BlueskyIcon,
|
||||||
|
TumblrIcon,
|
||||||
TwitterIcon,
|
TwitterIcon,
|
||||||
MastodonIcon,
|
MastodonIcon,
|
||||||
GithubIcon,
|
GitHubIcon,
|
||||||
ScaleIcon,
|
ScaleIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import {
|
import {
|
||||||
@@ -1184,6 +1185,13 @@ const socialLinks = [
|
|||||||
icon: MastodonIcon,
|
icon: MastodonIcon,
|
||||||
rel: "me",
|
rel: "me",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.social.tumblr", defaultMessage: "Tumblr" }),
|
||||||
|
),
|
||||||
|
href: "https://tumblr.com/modrinth",
|
||||||
|
icon: TumblrIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
|
label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
|
||||||
href: "https://x.com/modrinth",
|
href: "https://x.com/modrinth",
|
||||||
@@ -1194,7 +1202,7 @@ const socialLinks = [
|
|||||||
defineMessage({ id: "layout.footer.social.github", defaultMessage: "GitHub" }),
|
defineMessage({ id: "layout.footer.social.github", defaultMessage: "GitHub" }),
|
||||||
),
|
),
|
||||||
href: "https://github.com/modrinth",
|
href: "https://github.com/modrinth",
|
||||||
icon: GithubIcon,
|
icon: GitHubIcon,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1203,9 +1211,9 @@ const footerLinks = [
|
|||||||
label: formatMessage(defineMessage({ id: "layout.footer.about", defaultMessage: "About" })),
|
label: formatMessage(defineMessage({ id: "layout.footer.about", defaultMessage: "About" })),
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
href: "/news",
|
href: "https://blog.modrinth.com",
|
||||||
label: formatMessage(
|
label: formatMessage(
|
||||||
defineMessage({ id: "layout.footer.about.news", defaultMessage: "News" }),
|
defineMessage({ id: "layout.footer.about.blog", defaultMessage: "Blog" }),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1338,15 +1346,6 @@ const footerLinks = [
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: "/legal/copyright",
|
|
||||||
label: formatMessage(
|
|
||||||
defineMessage({
|
|
||||||
id: "layout.footer.legal.copyright-policy",
|
|
||||||
defaultMessage: "Copyright Policy and DMCA",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -383,15 +383,15 @@
|
|||||||
"layout.footer.about": {
|
"layout.footer.about": {
|
||||||
"message": "About"
|
"message": "About"
|
||||||
},
|
},
|
||||||
|
"layout.footer.about.blog": {
|
||||||
|
"message": "Blog"
|
||||||
|
},
|
||||||
"layout.footer.about.careers": {
|
"layout.footer.about.careers": {
|
||||||
"message": "Careers"
|
"message": "Careers"
|
||||||
},
|
},
|
||||||
"layout.footer.about.changelog": {
|
"layout.footer.about.changelog": {
|
||||||
"message": "Changelog"
|
"message": "Changelog"
|
||||||
},
|
},
|
||||||
"layout.footer.about.news": {
|
|
||||||
"message": "News"
|
|
||||||
},
|
|
||||||
"layout.footer.about.rewards-program": {
|
"layout.footer.about.rewards-program": {
|
||||||
"message": "Rewards Program"
|
"message": "Rewards Program"
|
||||||
},
|
},
|
||||||
@@ -404,9 +404,6 @@
|
|||||||
"layout.footer.legal-disclaimer": {
|
"layout.footer.legal-disclaimer": {
|
||||||
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
|
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
|
||||||
},
|
},
|
||||||
"layout.footer.legal.copyright-policy": {
|
|
||||||
"message": "Copyright Policy and DMCA"
|
|
||||||
},
|
|
||||||
"layout.footer.legal.privacy-policy": {
|
"layout.footer.legal.privacy-policy": {
|
||||||
"message": "Privacy Policy"
|
"message": "Privacy Policy"
|
||||||
},
|
},
|
||||||
@@ -461,6 +458,9 @@
|
|||||||
"layout.footer.social.mastodon": {
|
"layout.footer.social.mastodon": {
|
||||||
"message": "Mastodon"
|
"message": "Mastodon"
|
||||||
},
|
},
|
||||||
|
"layout.footer.social.tumblr": {
|
||||||
|
"message": "Tumblr"
|
||||||
|
},
|
||||||
"layout.footer.social.x": {
|
"layout.footer.social.x": {
|
||||||
"message": "X"
|
"message": "X"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,11 +29,12 @@
|
|||||||
class="settings-header__icon"
|
class="settings-header__icon"
|
||||||
/>
|
/>
|
||||||
<div class="settings-header__text">
|
<div class="settings-header__text">
|
||||||
<h1 class="wrap-as-needed">{{ project.title }}</h1>
|
<h1 class="wrap-as-needed">
|
||||||
|
{{ project.title }}
|
||||||
|
</h1>
|
||||||
<ProjectStatusBadge :status="project.status" />
|
<ProjectStatusBadge :status="project.status" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Project settings</h2>
|
<h2>Project settings</h2>
|
||||||
<NavStack>
|
<NavStack>
|
||||||
<NavStackItem
|
<NavStackItem
|
||||||
@@ -110,7 +111,6 @@
|
|||||||
</NavStack>
|
</NavStack>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="normal-page__content">
|
<div class="normal-page__content">
|
||||||
<ProjectMemberHeader
|
<ProjectMemberHeader
|
||||||
v-if="currentMember"
|
v-if="currentMember"
|
||||||
@@ -145,7 +145,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="experimental-styles-within">
|
<div v-else class="experimental-styles-within">
|
||||||
<NewModal ref="settingsModal">
|
<NewModal ref="settingsModal">
|
||||||
<template #title>
|
<template #title>
|
||||||
@@ -175,11 +174,9 @@
|
|||||||
<div
|
<div
|
||||||
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
|
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
|
||||||
>
|
>
|
||||||
@@ -222,7 +219,8 @@
|
|||||||
:href="`modrinth://mod/${project.slug}`"
|
:href="`modrinth://mod/${project.slug}`"
|
||||||
@click="() => installWithApp()"
|
@click="() => installWithApp()"
|
||||||
>
|
>
|
||||||
<ModrinthIcon aria-hidden="true" /> Install with Modrinth App
|
<ModrinthIcon aria-hidden="true" />
|
||||||
|
Install with Modrinth App
|
||||||
<ExternalIcon aria-hidden="true" />
|
<ExternalIcon aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@@ -242,7 +240,6 @@
|
|||||||
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-auto flex w-fit flex-col gap-2">
|
<div class="mx-auto flex w-fit flex-col gap-2">
|
||||||
<ButtonStyled v-if="project.game_versions.length === 1">
|
<ButtonStyled v-if="project.game_versions.length === 1">
|
||||||
<div class="disabled button-like">
|
<div class="disabled button-like">
|
||||||
@@ -330,7 +327,8 @@
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ gameVersion }} <CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
{{ gameVersion }}
|
||||||
|
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</ScrollablePanel>
|
</ScrollablePanel>
|
||||||
@@ -421,6 +419,7 @@
|
|||||||
</ScrollablePanel>
|
</ScrollablePanel>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AutomaticAccordion div class="flex flex-col gap-2">
|
<AutomaticAccordion div class="flex flex-col gap-2">
|
||||||
<VersionSummary
|
<VersionSummary
|
||||||
v-if="filteredRelease"
|
v-if="filteredRelease"
|
||||||
@@ -471,14 +470,10 @@
|
|||||||
class="new-page sidebar"
|
class="new-page sidebar"
|
||||||
:class="{
|
:class="{
|
||||||
'alt-layout': cosmetics.leftContentLayout,
|
'alt-layout': cosmetics.leftContentLayout,
|
||||||
'checklist-open':
|
'ultimate-sidebar':
|
||||||
showModerationChecklist &&
|
showModerationChecklist &&
|
||||||
!collapsedModerationChecklist &&
|
!collapsedModerationChecklist &&
|
||||||
!flags.alwaysShowChecklistAsPopup,
|
!flags.alwaysShowChecklistAsPopup,
|
||||||
'checklist-collapsed':
|
|
||||||
showModerationChecklist &&
|
|
||||||
collapsedModerationChecklist &&
|
|
||||||
!flags.alwaysShowChecklistAsPopup,
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="normal-page__header relative my-4">
|
<div class="normal-page__header relative my-4">
|
||||||
@@ -490,11 +485,11 @@
|
|||||||
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
|
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
|
||||||
>
|
>
|
||||||
<button @click="(event) => downloadModal.show(event)">
|
<button @click="(event) => downloadModal.show(event)">
|
||||||
<DownloadIcon aria-hidden="true" /> Download
|
<DownloadIcon aria-hidden="true" />
|
||||||
|
Download
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="contents sm:hidden">
|
<div class="contents sm:hidden">
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
size="large"
|
size="large"
|
||||||
@@ -559,11 +554,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
||||||
Modrinth Servers is the easiest way to play with your friends without hassle!
|
Modrinth Servers is the easiest way to play with your friends without hassle!
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="m-0 text-wrap text-sm font-bold text-primary">
|
<p class="m-0 text-wrap text-sm font-bold text-primary">
|
||||||
Starting at $5<span class="text-xs"> / month</span>
|
Starting at $5<span class="text-xs"> / month</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -628,7 +621,6 @@
|
|||||||
{{ option.name }}
|
{{ option.name }}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="menu-text">
|
<div v-else class="menu-text">
|
||||||
<p class="popout-text">No collections found.</p>
|
<p class="popout-text">No collections found.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -636,7 +628,8 @@
|
|||||||
class="btn collection-button"
|
class="btn collection-button"
|
||||||
@click="(event) => $refs.modal_collection.show(event)"
|
@click="(event) => $refs.modal_collection.show(event)"
|
||||||
>
|
>
|
||||||
<PlusIcon aria-hidden="true" /> Create new collection
|
<PlusIcon aria-hidden="true" />
|
||||||
|
Create new collection
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</PopoutMenu>
|
</PopoutMenu>
|
||||||
@@ -719,14 +712,25 @@
|
|||||||
:dropdown-id="`${baseId}-more-options`"
|
:dropdown-id="`${baseId}-more-options`"
|
||||||
>
|
>
|
||||||
<MoreVerticalIcon aria-hidden="true" />
|
<MoreVerticalIcon aria-hidden="true" />
|
||||||
<template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template>
|
<template #analytics>
|
||||||
<template #moderation-checklist>
|
<ChartIcon aria-hidden="true" />
|
||||||
<ScaleIcon aria-hidden="true" /> Review project
|
Analytics
|
||||||
|
</template>
|
||||||
|
<template #moderation-checklist>
|
||||||
|
<ScaleIcon aria-hidden="true" />
|
||||||
|
Review project
|
||||||
|
</template>
|
||||||
|
<template #report>
|
||||||
|
<ReportIcon aria-hidden="true" />
|
||||||
|
Report
|
||||||
|
</template>
|
||||||
|
<template #copy-id>
|
||||||
|
<ClipboardCopyIcon aria-hidden="true" />
|
||||||
|
Copy ID
|
||||||
</template>
|
</template>
|
||||||
<template #report> <ReportIcon aria-hidden="true" /> Report </template>
|
|
||||||
<template #copy-id> <ClipboardCopyIcon aria-hidden="true" /> Copy ID </template>
|
|
||||||
<template #copy-permalink>
|
<template #copy-permalink>
|
||||||
<ClipboardCopyIcon aria-hidden="true" /> Copy permanent link
|
<ClipboardCopyIcon aria-hidden="true" />
|
||||||
|
Copy permanent link
|
||||||
</template>
|
</template>
|
||||||
</OverflowMenu>
|
</OverflowMenu>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@@ -752,14 +756,18 @@
|
|||||||
updates unless the author decides to unarchive the project.
|
updates unless the author decides to unarchive the project.
|
||||||
</MessageBanner>
|
</MessageBanner>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="normal-page__sidebar">
|
<div class="normal-page__sidebar">
|
||||||
<ProjectSidebarCompatibility
|
<ProjectSidebarCompatibility
|
||||||
:project="project"
|
:project="project"
|
||||||
:tags="tags"
|
:tags="tags"
|
||||||
class="card flex-card experimental-styles-within"
|
class="card flex-card experimental-styles-within"
|
||||||
/>
|
/>
|
||||||
<AdPlaceholder v-if="!auth.user && tags.approvedStatuses.includes(project.status)" />
|
<AdPlaceholder
|
||||||
|
v-if="
|
||||||
|
(!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus) &&
|
||||||
|
tags.approvedStatuses.includes(project.status)
|
||||||
|
"
|
||||||
|
/>
|
||||||
<ProjectSidebarLinks
|
<ProjectSidebarLinks
|
||||||
:project="project"
|
:project="project"
|
||||||
:link-target="$external()"
|
:link-target="$external()"
|
||||||
@@ -782,7 +790,6 @@
|
|||||||
/>
|
/>
|
||||||
<div class="card flex-card experimental-styles-within">
|
<div class="card flex-card experimental-styles-within">
|
||||||
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
|
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
|
||||||
|
|
||||||
<div class="details-list">
|
<div class="details-list">
|
||||||
<div class="details-list__item">
|
<div class="details-list__item">
|
||||||
<BookTextIcon aria-hidden="true" />
|
<BookTextIcon aria-hidden="true" />
|
||||||
@@ -811,48 +818,53 @@
|
|||||||
<span v-else>{{ licenseIdDisplay }}</span>
|
<span v-else>{{ licenseIdDisplay }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="project.approved"
|
v-if="project.approved"
|
||||||
v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
|
v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
|
||||||
class="details-list__item"
|
class="details-list__item"
|
||||||
>
|
>
|
||||||
<CalendarIcon aria-hidden="true" />
|
<CalendarIcon aria-hidden="true" />
|
||||||
<div>{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}</div>
|
<div>
|
||||||
|
{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
|
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
|
||||||
class="details-list__item"
|
class="details-list__item"
|
||||||
>
|
>
|
||||||
<CalendarIcon aria-hidden="true" />
|
<CalendarIcon aria-hidden="true" />
|
||||||
<div>{{ formatMessage(detailsMessages.created, { date: createdDate }) }}</div>
|
<div>
|
||||||
|
{{ formatMessage(detailsMessages.created, { date: createdDate }) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="project.status === 'processing' && project.queued"
|
v-if="project.status === 'processing' && project.queued"
|
||||||
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
|
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
|
||||||
class="details-list__item"
|
class="details-list__item"
|
||||||
>
|
>
|
||||||
<ScaleIcon aria-hidden="true" />
|
<ScaleIcon aria-hidden="true" />
|
||||||
<div>{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}</div>
|
<div>
|
||||||
|
{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="versions.length > 0 && project.updated"
|
v-if="versions.length > 0 && project.updated"
|
||||||
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
|
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
|
||||||
class="details-list__item"
|
class="details-list__item"
|
||||||
>
|
>
|
||||||
<VersionIcon aria-hidden="true" />
|
<VersionIcon aria-hidden="true" />
|
||||||
<div>{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}</div>
|
<div>
|
||||||
|
{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="normal-page__content">
|
<div class="normal-page__content">
|
||||||
<div class="overflow-x-auto"><NavTabs :links="navLinks" class="mb-4" /></div>
|
<div class="overflow-x-auto">
|
||||||
|
<NavTabs :links="navLinks" class="mb-4" />
|
||||||
|
</div>
|
||||||
<NuxtPage
|
<NuxtPage
|
||||||
v-model:project="project"
|
v-model:project="project"
|
||||||
v-model:versions="versions"
|
v-model:versions="versions"
|
||||||
@@ -870,10 +882,8 @@
|
|||||||
@delete-version="deleteVersion"
|
@delete-version="deleteVersion"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="normal-page__ultimate-sidebar">
|
<div class="normal-page__ultimate-sidebar">
|
||||||
<!-- Uncomment this to enable the old moderation checklist. -->
|
<ModerationChecklist
|
||||||
<!-- <ModerationChecklist
|
|
||||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||||
:project="project"
|
:project="project"
|
||||||
:future-projects="futureProjects"
|
:future-projects="futureProjects"
|
||||||
@@ -881,25 +891,11 @@
|
|||||||
:collapsed="collapsedModerationChecklist"
|
:collapsed="collapsedModerationChecklist"
|
||||||
@exit="showModerationChecklist = false"
|
@exit="showModerationChecklist = false"
|
||||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||||
/> -->
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
|
||||||
class="moderation-checklist"
|
|
||||||
>
|
|
||||||
<NewModerationChecklist
|
|
||||||
:project="project"
|
|
||||||
:future-project-ids="futureProjectIds"
|
|
||||||
:collapsed="collapsedModerationChecklist"
|
|
||||||
@exit="showModerationChecklist = false"
|
|
||||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
BookmarkIcon,
|
BookmarkIcon,
|
||||||
@@ -959,16 +955,16 @@ import {
|
|||||||
isUnderReview,
|
isUnderReview,
|
||||||
renderString,
|
renderString,
|
||||||
} from "@modrinth/utils";
|
} from "@modrinth/utils";
|
||||||
|
import { navigateTo } from "#app";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { Tooltip } from "floating-vue";
|
import { Tooltip } from "floating-vue";
|
||||||
import { useLocalStorage } from "@vueuse/core";
|
|
||||||
import { navigateTo } from "#app";
|
|
||||||
import Accordion from "~/components/ui/Accordion.vue";
|
import Accordion from "~/components/ui/Accordion.vue";
|
||||||
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||||
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
||||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||||
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
||||||
|
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
|
||||||
import NavStack from "~/components/ui/NavStack.vue";
|
import NavStack from "~/components/ui/NavStack.vue";
|
||||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
||||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||||
@@ -976,7 +972,6 @@ import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
|
|||||||
import { userCollectProject } from "~/composables/user.js";
|
import { userCollectProject } from "~/composables/user.js";
|
||||||
import { reportProject } from "~/utils/report-helpers.ts";
|
import { reportProject } from "~/utils/report-helpers.ts";
|
||||||
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
||||||
import NewModerationChecklist from "~/components/ui/moderation/NewModerationChecklist.vue";
|
|
||||||
|
|
||||||
const data = useNuxtApp();
|
const data = useNuxtApp();
|
||||||
const route = useNativeRoute();
|
const route = useNativeRoute();
|
||||||
@@ -990,7 +985,6 @@ const flags = useFeatureFlags();
|
|||||||
const cosmetics = useCosmetics();
|
const cosmetics = useCosmetics();
|
||||||
|
|
||||||
const { formatMessage } = useVIntl();
|
const { formatMessage } = useVIntl();
|
||||||
const { setVisible } = useNotificationRightwards();
|
|
||||||
|
|
||||||
const settingsModal = ref();
|
const settingsModal = ref();
|
||||||
const downloadModal = ref();
|
const downloadModal = ref();
|
||||||
@@ -1562,28 +1556,12 @@ async function copyPermalink() {
|
|||||||
|
|
||||||
const collapsedChecklist = ref(false);
|
const collapsedChecklist = ref(false);
|
||||||
|
|
||||||
const showModerationChecklist = useLocalStorage(
|
const showModerationChecklist = ref(false);
|
||||||
`show-moderation-checklist-${project.value.id}`,
|
const collapsedModerationChecklist = ref(false);
|
||||||
false,
|
const futureProjects = ref([]);
|
||||||
);
|
|
||||||
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
|
|
||||||
|
|
||||||
const futureProjectIds = useLocalStorage("moderation-future-projects", []);
|
|
||||||
|
|
||||||
watch(futureProjectIds, (newValue) => {
|
|
||||||
console.log("Future project IDs updated:", newValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
showModerationChecklist,
|
|
||||||
(newValue) => {
|
|
||||||
setVisible(newValue);
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (import.meta.client && history && history.state && history.state.showChecklist) {
|
if (import.meta.client && history && history.state && history.state.showChecklist) {
|
||||||
showModerationChecklist.value = true;
|
showModerationChecklist.value = true;
|
||||||
|
futureProjects.value = history.state.projects;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDownloadModal(event) {
|
function closeDownloadModal(event) {
|
||||||
@@ -1653,7 +1631,6 @@ const navLinks = computed(() => {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.settings-header {
|
.settings-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1809,16 +1786,4 @@ const navLinks = computed(() => {
|
|||||||
left: 18px;
|
left: 18px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.moderation-checklist {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
z-index: 50;
|
|
||||||
|
|
||||||
> div {
|
|
||||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -705,9 +705,9 @@ export default defineNuxtComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gallery-body {
|
.gallery-body {
|
||||||
|
flex-grow: 1;
|
||||||
width: calc(100% - 2 * var(--spacing-card-md));
|
width: calc(100% - 2 * var(--spacing-card-md));
|
||||||
padding: var(--spacing-card-sm) var(--spacing-card-md);
|
padding: var(--spacing-card-sm) var(--spacing-card-md);
|
||||||
overflow-wrap: anywhere;
|
|
||||||
|
|
||||||
.gallery-info {
|
.gallery-info {
|
||||||
h2 {
|
h2 {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
Listed in search results
|
Listed in search results
|
||||||
</li>
|
</li>
|
||||||
<li v-else>
|
<li v-else>
|
||||||
<XIcon class="bad" />
|
<ExitIcon class="bad" />
|
||||||
Not listed in search results
|
Not listed in search results
|
||||||
</li>
|
</li>
|
||||||
<li v-if="isListed(project)">
|
<li v-if="isListed(project)">
|
||||||
@@ -58,11 +58,11 @@
|
|||||||
Listed on the profiles of members
|
Listed on the profiles of members
|
||||||
</li>
|
</li>
|
||||||
<li v-else>
|
<li v-else>
|
||||||
<XIcon class="bad" />
|
<ExitIcon class="bad" />
|
||||||
Not listed on the profiles of members
|
Not listed on the profiles of members
|
||||||
</li>
|
</li>
|
||||||
<li v-if="isPrivate(project)">
|
<li v-if="isPrivate(project)">
|
||||||
<XIcon class="bad" />
|
<ExitIcon class="bad" />
|
||||||
Not accessible with a direct link
|
Not accessible with a direct link
|
||||||
</li>
|
</li>
|
||||||
<li v-else>
|
<li v-else>
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { XIcon, CheckIcon, IssuesIcon } from "@modrinth/assets";
|
import { ExitIcon, CheckIcon, IssuesIcon } from "@modrinth/assets";
|
||||||
import { Badge } from "@modrinth/ui";
|
import { Badge } from "@modrinth/ui";
|
||||||
import ConversationThread from "~/components/ui/thread/ConversationThread.vue";
|
import ConversationThread from "~/components/ui/thread/ConversationThread.vue";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
{{ formatProjectType(project.project_type).toLowerCase() }}. You may choose one from our
|
{{ formatProjectType(project.project_type).toLowerCase() }}. You may choose one from our
|
||||||
list or provide a custom license. You may also provide a custom URL to your chosen license;
|
list or provide a custom license. You may also provide a custom URL to your chosen license;
|
||||||
otherwise, the license text will be displayed. See our
|
otherwise, the license text will be displayed. See our
|
||||||
<nuxt-link
|
<a
|
||||||
to="/news/article/licensing-guide/"
|
href="https://blog.modrinth.com/licensing-guide/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
class="text-link"
|
class="text-link"
|
||||||
>
|
>
|
||||||
licensing guide
|
licensing guide
|
||||||
</nuxt-link>
|
</a>
|
||||||
for more information.
|
for more information.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,10 @@
|
|||||||
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
|
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
|
||||||
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
|
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
|
||||||
</span>
|
</span>
|
||||||
<div class="flex w-full items-center gap-1 text-xs text-secondary">
|
<div
|
||||||
|
v-if="flags.developerMode"
|
||||||
|
class="flex w-full items-center gap-1 text-xs text-secondary"
|
||||||
|
>
|
||||||
{{ charge.status }}
|
{{ charge.status }}
|
||||||
⋅
|
⋅
|
||||||
{{ charge.type }}
|
{{ charge.type }}
|
||||||
@@ -216,6 +219,7 @@ import dayjs from "dayjs";
|
|||||||
import { products } from "~/generated/state.json";
|
import { products } from "~/generated/state.json";
|
||||||
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
|
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
|
||||||
|
|
||||||
|
const flags = useFeatureFlags();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const data = useNuxtApp();
|
const data = useNuxtApp();
|
||||||
const vintl = useVIntl();
|
const vintl = useVIntl();
|
||||||
@@ -285,13 +289,13 @@ const selectedCharge = ref(null);
|
|||||||
const refundType = ref("full");
|
const refundType = ref("full");
|
||||||
const refundTypes = ref(["full", "partial", "none"]);
|
const refundTypes = ref(["full", "partial", "none"]);
|
||||||
const refundAmount = ref(0);
|
const refundAmount = ref(0);
|
||||||
const unprovision = ref(true);
|
const unprovision = ref(false);
|
||||||
|
|
||||||
function showRefundModal(charge) {
|
function showRefundModal(charge) {
|
||||||
selectedCharge.value = charge;
|
selectedCharge.value = charge;
|
||||||
refundType.value = "full";
|
refundType.value = "full";
|
||||||
refundAmount.value = 0;
|
refundAmount.value = 0;
|
||||||
unprovision.value = true;
|
unprovision.value = false;
|
||||||
refundModal.value.show();
|
refundModal.value.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -248,7 +248,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<AdPlaceholder v-if="!auth.user" />
|
<AdPlaceholder
|
||||||
|
v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="normal-page__content">
|
<div class="normal-page__content">
|
||||||
<nav class="navigation-card">
|
<nav class="navigation-card">
|
||||||
@@ -490,6 +492,7 @@ const route = useNativeRoute();
|
|||||||
const auth = await useAuth();
|
const auth = await useAuth();
|
||||||
const cosmetics = useCosmetics();
|
const cosmetics = useCosmetics();
|
||||||
const tags = useTags();
|
const tags = useTags();
|
||||||
|
const flags = useFeatureFlags();
|
||||||
|
|
||||||
const isEditing = ref(false);
|
const isEditing = ref(false);
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,7 @@
|
|||||||
/>
|
/>
|
||||||
<div class="push-right input-group">
|
<div class="push-right input-group">
|
||||||
<button class="iconified-button" @click="$refs.editLinksModal.hide()">
|
<button class="iconified-button" @click="$refs.editLinksModal.hide()">
|
||||||
<XIcon />
|
<CrossIcon />
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button class="iconified-button brand-button" @click="bulkEditLinks()">
|
<button class="iconified-button brand-button" @click="bulkEditLinks()">
|
||||||
@@ -199,8 +199,8 @@
|
|||||||
class="square-button"
|
class="square-button"
|
||||||
@click="updateDescending()"
|
@click="updateDescending()"
|
||||||
>
|
>
|
||||||
<SortDescIcon v-if="descending" />
|
<DescendingIcon v-if="descending" />
|
||||||
<SortAscIcon v-else />
|
<AscendingIcon v-else />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,12 +306,12 @@ import {
|
|||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
XIcon,
|
XIcon as CrossIcon,
|
||||||
IssuesIcon,
|
IssuesIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
SortAscIcon,
|
SortAscendingIcon as AscendingIcon,
|
||||||
SortDescIcon,
|
SortDescendingIcon as DescendingIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -337,15 +337,15 @@ export default defineNuxtComponent({
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
IssuesIcon,
|
IssuesIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
XIcon,
|
CrossIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
Modal,
|
Modal,
|
||||||
ModalCreation,
|
ModalCreation,
|
||||||
Multiselect,
|
Multiselect,
|
||||||
CopyCode,
|
CopyCode,
|
||||||
SortAscIcon,
|
AscendingIcon,
|
||||||
SortDescIcon,
|
DescendingIcon,
|
||||||
},
|
},
|
||||||
async setup() {
|
async setup() {
|
||||||
const { formatMessage } = useVIntl();
|
const { formatMessage } = useVIntl();
|
||||||
|
|||||||
@@ -266,12 +266,12 @@ const getRangeOfMethod = (method) => {
|
|||||||
|
|
||||||
const maxWithdrawAmount = computed(() => {
|
const maxWithdrawAmount = computed(() => {
|
||||||
const interval = selectedMethod.value.interval;
|
const interval = selectedMethod.value.interval;
|
||||||
return interval?.standard ? interval.standard.max : (interval?.fixed?.values.slice(-1)[0] ?? 0);
|
return interval?.standard ? interval.standard.max : interval?.fixed?.values.slice(-1)[0] ?? 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const minWithdrawAmount = computed(() => {
|
const minWithdrawAmount = computed(() => {
|
||||||
const interval = selectedMethod.value.interval;
|
const interval = selectedMethod.value.interval;
|
||||||
return interval?.standard ? interval.standard.min : (interval?.fixed?.values?.[0] ?? fees.value);
|
return interval?.standard ? interval.standard.min : interval?.fixed?.values?.[0] ?? fees.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const withdrawAccount = computed(() => {
|
const withdrawAccount = computed(() => {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -147,9 +147,9 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in platformRevenueData" :key="item.time">
|
<tr v-for="item in platformRevenueData" :key="item.time">
|
||||||
<td>{{ formatDate(dayjs.unix(item.time)) }}</td>
|
<td>{{ formatDate(dayjs.unix(item.time)) }}</td>
|
||||||
<td>{{ formatMoney(Number(item.revenue) + Number(item.creator_revenue)) }}</td>
|
<td>{{ formatMoney(item.revenue) }}</td>
|
||||||
<td>{{ formatMoney(Number(item.creator_revenue)) }}</td>
|
<td>{{ formatMoney(item.creator_revenue) }}</td>
|
||||||
<td>{{ formatMoney(Number(item.revenue)) }}</td>
|
<td>{{ formatMoney(item.revenue - item.creator_revenue) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -187,6 +187,6 @@ const { data: transparencyInformation } = await useAsyncData("payout/platform_re
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const platformRevenue = (transparencyInformation.value as any)?.all_time;
|
const platformRevenue = transparencyInformation.value.all_time;
|
||||||
const platformRevenueData = (transparencyInformation.value as any)?.data?.slice(0, 5) ?? [];
|
const platformRevenueData = transparencyInformation.value.data.slice(0, 5);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user