Compare commits
1 Commits
v0.10.2
...
cal/dev-76
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf309fd641 |
@@ -1,6 +1,6 @@
|
||||
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
|
||||
[target.'cfg(windows)']
|
||||
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
|
||||
rustflags = ["-C", "link-args=/STACK:16777220"]
|
||||
|
||||
[build]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
||||
|
||||
@@ -14,5 +14,5 @@ max_line_length = 100
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{rs,java,kts}]
|
||||
indent_size = 4
|
||||
[*.rs]
|
||||
indent_size = 4
|
||||
34
.gitattributes
vendored
34
.gitattributes
vendored
@@ -1,35 +1 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
# SQLx calculates a checksum of migration scripts at build time to compare
|
||||
# it with the checksum of the applied migration for the same version at
|
||||
# runtime, to know if the migration script has been changed, and thus the
|
||||
# DB schema went out of sync with the code.
|
||||
#
|
||||
# However, such checksum treats the script as a raw byte stream, taking
|
||||
# into account inconsequential differences like different line endings
|
||||
# in different OSes. When combined with Git's EOL conversion and mixed
|
||||
# native and cross-compilation scenarios, this leads to existing
|
||||
# migrations that didn't change having potentially different checksums
|
||||
# according to the environment they were built in, which can break the
|
||||
# migration system when deploying the Modrinth App, rendering it
|
||||
# unusable.
|
||||
#
|
||||
# The gitattribute above ensures that all text files are checked out
|
||||
# with LF line endings, but widely deployed app versions were built
|
||||
# without this attribute set, which left such line endings variable to
|
||||
# the platform. Thus, there is no perfect solution to this problem:
|
||||
# forcing CRLF here would break Linux and macOS users, forcing LF
|
||||
# breaks Windows users, and leaving it unspecified may still lead to
|
||||
# line ending differences when cross-compiling from Linux to Windows
|
||||
# or vice versa, or having Git configured with different line
|
||||
# conversion settings. Moreover, there is no `eol=native` attribute,
|
||||
# and using CI-only scripts to convert line endings would make the
|
||||
# builds differ between CI and most local environments. So, let's pick
|
||||
# the least bad option: let Git handle line endings using its
|
||||
# configuration by leaving it unspecified, which works fine as long as
|
||||
# people don't mess with Git's line ending settings, which is the vast
|
||||
# majority of cases.
|
||||
/packages/app-lib/migrations/20240711194701_init.sql !eol
|
||||
/packages/app-lib/migrations/20240813205023_drop-active-unique.sql !eol
|
||||
/packages/app-lib/migrations/20240930001852_disable-personalized-ads.sql !eol
|
||||
/packages/app-lib/migrations/20241222013857_feature-flags.sql !eol
|
||||
|
||||
25
.github/CODEOWNERS
vendored
Normal file
25
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
/apps/frontend/ @modrinth/frontend
|
||||
/apps/app-frontend/ @modrinth/frontend
|
||||
/apps/daedalus_client/ @modrinth/backend
|
||||
/apps/docs/ @modrinth/support @modrinth/platform @modrinth/servers
|
||||
/apps/labrinth @modrinth/backend
|
||||
/apps/app @modrinth/backend
|
||||
|
||||
/packages/app-lib/ @modrinth/backend
|
||||
/packages/ariadne/ @modrinth/backend
|
||||
/packages/assets/ @modrinth/frontend
|
||||
/packages/daedalus/ @modrinth/backend
|
||||
/packages/eslint-config-custom/ @modrinth/frontend
|
||||
/packages/tsconfig/ @modrinth/frontend
|
||||
/packages/ui/ @modrinth/frontend
|
||||
/packages/utils/ @modrinth/frontend
|
||||
|
||||
README.md @modrinth/support
|
||||
|
||||
LICENSE @Geometrically @Prospector
|
||||
/COPYING.md @Geometrically @Prospector
|
||||
/apps/frontend/src/pages/legal/ @Geometrically @Prospector
|
||||
|
||||
/.github/ @Geometrically @Prospector
|
||||
|
||||
/docker-compose.yml @modrinth/backend
|
||||
151
.github/workflows/theseus-build.yml
vendored
151
.github/workflows/theseus-build.yml
vendored
@@ -1,151 +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: 2
|
||||
|
||||
- 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
|
||||
env:
|
||||
APP_VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || format('v1.0.0-canary+{0}', github.sha) }}
|
||||
run: |
|
||||
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: 🗑️ Clean up cached bundles
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf target/release/bundle
|
||||
rm -rf target/*/release/bundle || true
|
||||
|
||||
- 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/**
|
||||
target/*/release/bundle/**
|
||||
245
.github/workflows/theseus-release.yml
vendored
245
.github/workflows/theseus-release.yml
vendored
@@ -1,118 +1,157 @@
|
||||
name: Modrinth App release
|
||||
name: 'Modrinth App build'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
paths:
|
||||
- .github/workflows/theseus-release.yml
|
||||
- 'apps/app/**'
|
||||
- 'apps/app-frontend/**'
|
||||
- 'apps/labrinth/src/common/**'
|
||||
- 'apps/labrinth/Cargo.toml'
|
||||
- 'packages/app-lib/**'
|
||||
- 'packages/app-macros/**'
|
||||
- 'packages/assets/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/utils/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version-tag:
|
||||
description: Version tag to release to the wide public
|
||||
type: string
|
||||
required: true
|
||||
release-notes:
|
||||
description: Release notes to include in the Tauri version manifest
|
||||
default: A new release of the Modrinth App is available!
|
||||
type: string
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release Modrinth App
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [macos-latest, windows-latest, ubuntu-22.04]
|
||||
|
||||
env:
|
||||
LINUX_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-unknown-linux-gnu)
|
||||
WINDOWS_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-pc-windows-msvc)
|
||||
MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME: App bundle (universal-apple-darwin)
|
||||
LAUNCHER_FILES_BUCKET_BASE_URL: https://launcher-files.modrinth.com
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: 📥 Download Modrinth App artifacts
|
||||
uses: dawidd6/action-download-artifact@v11
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Rust setup (mac)
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
workflow: theseus-build.yml
|
||||
workflow_conclusion: success
|
||||
event: push
|
||||
branch: ${{ inputs.version-tag }}
|
||||
use_unzip: true
|
||||
rustflags: ''
|
||||
target: x86_64-apple-darwin
|
||||
|
||||
- name: 🛠️ Generate version manifest
|
||||
env:
|
||||
VERSION_TAG: ${{ inputs.version-tag }}
|
||||
RELEASE_NOTES: ${{ inputs.release-notes }}
|
||||
- name: Rust setup
|
||||
if: "!startsWith(matrix.platform, 'macos')"
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
rustflags: ''
|
||||
|
||||
- name: Setup rust cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
target/**
|
||||
!target/*/release/bundle/*/*.dmg
|
||||
!target/*/release/bundle/*/*.app.tar.gz
|
||||
!target/*/release/bundle/*/*.app.tar.gz.sig
|
||||
!target/release/bundle/*/*.dmg
|
||||
!target/release/bundle/*/*.app.tar.gz
|
||||
!target/release/bundle/*/*.app.tar.gz.sig
|
||||
|
||||
!target/release/bundle/appimage/*.AppImage
|
||||
!target/release/bundle/appimage/*.AppImage.tar.gz
|
||||
!target/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
!target/release/bundle/deb/*.deb
|
||||
!target/release/bundle/rpm/*.rpm
|
||||
|
||||
!target/release/bundle/msi/*.msi
|
||||
!target/release/bundle/msi/*.msi.zip
|
||||
!target/release/bundle/msi/*.msi.zip.sig
|
||||
|
||||
!target/release/bundle/nsis/*.exe
|
||||
!target/release/bundle/nsis/*.nsis.zip
|
||||
!target/release/bundle/nsis/*.nsis.zip.sig
|
||||
key: ${{ runner.os }}-rust-target-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-rust-target-
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Install pnpm via corepack
|
||||
shell: bash
|
||||
run: |
|
||||
# Reference: https://tauri.app/plugin/updater/#server-support
|
||||
jq -nc \
|
||||
--arg versionTag "${VERSION_TAG#v}" \
|
||||
--arg releaseNotes "$RELEASE_NOTES" \
|
||||
--rawfile macOsAarch64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \
|
||||
--rawfile macOsX64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \
|
||||
--rawfile linuxX64UpdateArtifactSignature "${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/appimage/Modrinth App_${VERSION_TAG#v}_amd64.AppImage.tar.gz.sig" \
|
||||
--rawfile windowsX64UpdateArtifactSignature "${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/nsis/Modrinth App_${VERSION_TAG#v}_x64-setup.nsis.zip.sig" \
|
||||
'{
|
||||
"version": $versionTag,
|
||||
"notes": $releaseNotes,
|
||||
"pub_date": now | todateiso8601,
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
"signature": $macOsAarch64UpdateArtifactSignature,
|
||||
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App.app.tar.gz")",
|
||||
"install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App_" + $versionTag + "_universal.dmg")"]
|
||||
},
|
||||
"darwin-x86_64": {
|
||||
"signature": $macOsX64UpdateArtifactSignature,
|
||||
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App.app.tar.gz")",
|
||||
"install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App_" + $versionTag + "_universal.dmg")"]
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"signature": $linuxX64UpdateArtifactSignature,
|
||||
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.AppImage.tar.gz")",
|
||||
"install_urls": [
|
||||
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.deb")",
|
||||
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.AppImage")",
|
||||
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "-1.x86_64.rpm")"
|
||||
]
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"signature": $windowsX64UpdateArtifactSignature,
|
||||
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/windows/\("Modrinth App_" + $versionTag + "_x64-setup.nsis.zip")",
|
||||
"install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/windows/\("Modrinth App_" + $versionTag + "_x64-setup.exe")"]
|
||||
}
|
||||
}
|
||||
}' > updates.json
|
||||
corepack enable
|
||||
corepack prepare --activate
|
||||
|
||||
echo "Generated manifest for version ${VERSION_TAG}:"
|
||||
cat updates.json
|
||||
|
||||
- name: 📤 Upload release artifacts
|
||||
env:
|
||||
VERSION_TAG: ${{ inputs.version-tag }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.LAUNCHER_FILES_BUCKET_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.LAUNCHER_FILES_BUCKET_SECRET_ACCESS_KEY }}
|
||||
AWS_BUCKET: ${{ secrets.LAUNCHER_FILES_BUCKET_NAME }}
|
||||
AWS_REGION: ${{ secrets.LAUNCHER_FILES_BUCKET_REGION }}
|
||||
AWS_ENDPOINT_URL: ${{ secrets.LAUNCHER_FILES_BUCKET_ENDPOINT_URL }}
|
||||
AWS_PAGER: ''
|
||||
# Work around incompatible checksum behavior with some S3-like object storage providers,
|
||||
# such as Cloudflare R2. See:
|
||||
# - https://developers.cloudflare.com/r2/examples/aws/aws-cli/
|
||||
# - https://developers.cloudflare.com/r2/examples/aws/aws-sdk-java/
|
||||
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
|
||||
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
for macosBundleType in 'macos' 'dmg'; do
|
||||
aws s3 cp --recursive \
|
||||
"${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/${macosBundleType}" \
|
||||
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/macos"
|
||||
done
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
for linuxBundleType in 'appimage' 'deb' 'rpm'; do
|
||||
aws s3 cp --recursive \
|
||||
"${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${linuxBundleType}" \
|
||||
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/linux"
|
||||
done
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
for windowsBundleType in 'nsis'; do
|
||||
aws s3 cp --recursive \
|
||||
"${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${windowsBundleType}" \
|
||||
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/windows"
|
||||
done
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev pkg-config libayatana-appindicator3-dev librsvg2-dev
|
||||
|
||||
aws s3 cp updates.json "s3://${AWS_BUCKET}"
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: build app (macos)
|
||||
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config "tauri-release.conf.json"
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: build app
|
||||
run: pnpm --filter=@modrinth/app run tauri build --config "tauri-release.conf.json"
|
||||
id: build_os
|
||||
if: "!startsWith(matrix.platform, 'macos')"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: upload ${{ matrix.platform }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.platform }}
|
||||
path: |
|
||||
target/*/release/bundle/*/*.dmg
|
||||
target/*/release/bundle/*/*.app.tar.gz
|
||||
target/*/release/bundle/*/*.app.tar.gz.sig
|
||||
target/release/bundle/*/*.dmg
|
||||
target/release/bundle/*/*.app.tar.gz
|
||||
target/release/bundle/*/*.app.tar.gz.sig
|
||||
|
||||
target/release/bundle/*/*.AppImage
|
||||
target/release/bundle/*/*.AppImage.tar.gz
|
||||
target/release/bundle/*/*.AppImage.tar.gz.sig
|
||||
target/release/bundle/*/*.deb
|
||||
target/release/bundle/*/*.rpm
|
||||
|
||||
target/release/bundle/msi/*.msi
|
||||
target/release/bundle/msi/*.msi.zip
|
||||
target/release/bundle/msi/*.msi.zip.sig
|
||||
|
||||
target/release/bundle/nsis/*.exe
|
||||
target/release/bundle/nsis/*.nsis.zip
|
||||
target/release/bundle/nsis/*.nsis.zip.sig
|
||||
|
||||
2
.idea/code.iml
generated
2
.idea/code.iml
generated
@@ -17,4 +17,4 @@
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
</module>
|
||||
2
.idea/modules.xml
generated
2
.idea/modules.xml
generated
@@ -5,4 +5,4 @@
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/code.iml" filepath="$PROJECT_DIR$/.idea/code.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
614
Cargo.lock
generated
614
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
41
Cargo.toml
41
Cargo.toml
@@ -25,7 +25,7 @@ actix-ws = "0.3.0"
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
ariadne = { path = "packages/ariadne" }
|
||||
async_zip = "0.0.17"
|
||||
async-compression = { version = "0.4.25", default-features = false }
|
||||
async-compression = { version = "0.4.24", default-features = false }
|
||||
async-recursion = "1.1.1"
|
||||
async-stripe = { version = "0.41.0", default-features = false, features = [
|
||||
"runtime-tokio-hyper-rustls",
|
||||
@@ -37,7 +37,6 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [
|
||||
async-walkdir = "2.1.0"
|
||||
base64 = "0.22.1"
|
||||
bitflags = "2.9.1"
|
||||
bytemuck = "1.23.0"
|
||||
bytes = "1.10.1"
|
||||
censor = "0.3.0"
|
||||
chardetng = "0.1.17"
|
||||
@@ -48,7 +47,6 @@ color-thief = "0.2.2"
|
||||
console-subscriber = "0.4.1"
|
||||
daedalus = { path = "packages/daedalus" }
|
||||
dashmap = "6.1.0"
|
||||
data-url = "0.3.1"
|
||||
deadpool-redis = "0.21.1"
|
||||
dirs = "6.0.0"
|
||||
discord-rich-presence = "0.2.5"
|
||||
@@ -62,8 +60,6 @@ flate2 = "1.1.2"
|
||||
fs4 = { version = "0.13.1", default-features = false }
|
||||
futures = { version = "0.3.31", default-features = false }
|
||||
futures-util = "0.3.31"
|
||||
hashlink = "0.10.0"
|
||||
heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
hickory-resolver = "0.25.2"
|
||||
hmac = "0.12.1"
|
||||
@@ -93,7 +89,6 @@ notify = { version = "8.0.0", default-features = false }
|
||||
notify-debouncer-mini = { version = "0.6.0", default-features = false }
|
||||
p256 = "0.13.2"
|
||||
paste = "1.0.15"
|
||||
png = "0.17.16"
|
||||
prometheus = "0.14.0"
|
||||
quartz_nbt = "0.2.9"
|
||||
quick-xml = "0.37.5"
|
||||
@@ -101,9 +96,8 @@ rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
|
||||
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
|
||||
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
|
||||
regex = "1.11.1"
|
||||
reqwest = { version = "0.12.20", default-features = false }
|
||||
rgb = "0.8.50"
|
||||
rust_decimal = { version = "1.37.2", features = [
|
||||
reqwest = { version = "0.12.19", default-features = false }
|
||||
rust_decimal = { version = "1.37.1", features = [
|
||||
"serde-with-float",
|
||||
"serde-with-str",
|
||||
] }
|
||||
@@ -114,7 +108,7 @@ rust-s3 = { version = "0.35.1", default-features = false, features = [
|
||||
"tokio-rustls-tls",
|
||||
] }
|
||||
rusty-money = "0.4.1"
|
||||
sentry = { version = "0.41.0", default-features = false, features = [
|
||||
sentry = { version = "0.38.1", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
"debug-images",
|
||||
@@ -122,13 +116,13 @@ sentry = { version = "0.41.0", default-features = false, features = [
|
||||
"reqwest",
|
||||
"rustls",
|
||||
] }
|
||||
sentry-actix = "0.41.0"
|
||||
sentry-actix = "0.38.1"
|
||||
serde = "1.0.219"
|
||||
serde_bytes = "0.11.17"
|
||||
serde_cbor = "0.11.2"
|
||||
serde_ini = "0.2.0"
|
||||
serde_json = "1.0.140"
|
||||
serde_with = "3.13.0"
|
||||
serde_with = "3.12.0"
|
||||
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
|
||||
sha1 = "0.10.6"
|
||||
sha1_smol = { version = "1.0.1", features = ["std"] }
|
||||
@@ -137,19 +131,18 @@ spdx = "0.10.8"
|
||||
sqlx = { version = "0.8.6", default-features = false }
|
||||
sysinfo = { version = "0.35.2", default-features = false }
|
||||
tar = "0.4.44"
|
||||
tauri = "2.6.1"
|
||||
tauri-build = "2.3.0"
|
||||
tauri-plugin-deep-link = "2.4.0"
|
||||
tauri-plugin-dialog = "2.3.0"
|
||||
tauri-plugin-http = "2.5.0"
|
||||
tauri-plugin-opener = "2.4.0"
|
||||
tauri-plugin-os = "2.3.0"
|
||||
tauri-plugin-single-instance = "2.3.0"
|
||||
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
|
||||
tauri = "2.5.1"
|
||||
tauri-build = "2.2.0"
|
||||
tauri-plugin-deep-link = "2.3.0"
|
||||
tauri-plugin-dialog = "2.2.2"
|
||||
tauri-plugin-opener = "2.2.7"
|
||||
tauri-plugin-os = "2.2.1"
|
||||
tauri-plugin-single-instance = "2.2.4"
|
||||
tauri-plugin-updater = { version = "2.7.1", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
"zip",
|
||||
] }
|
||||
tauri-plugin-window-state = "2.3.0"
|
||||
tauri-plugin-window-state = "2.2.2"
|
||||
tempfile = "3.20.0"
|
||||
theseus = { path = "packages/app-lib" }
|
||||
thiserror = "2.0.12"
|
||||
@@ -172,7 +165,7 @@ whoami = "1.6.0"
|
||||
winreg = "0.55.0"
|
||||
woothee = "0.13.0"
|
||||
yaserde = "0.12.0"
|
||||
zip = { version = "4.2.0", default-features = false, features = [
|
||||
zip = { version = "4.0.0", default-features = false, features = [
|
||||
"bzip2",
|
||||
"deflate",
|
||||
"deflate64",
|
||||
@@ -219,7 +212,7 @@ wildcard_dependencies = "warn"
|
||||
warnings = "deny"
|
||||
|
||||
[patch.crates-io]
|
||||
wry = { git = "https://github.com/modrinth/wry", rev = "21db186" }
|
||||
wry = { git = "https://github.com/modrinth/wry", rev = "cafdaa9" }
|
||||
|
||||
# Optimize for speed and reduce size on release builds
|
||||
[profile.release]
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
**/dist
|
||||
*.gltf
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@modrinth/app-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0-local",
|
||||
"version": "0.9.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -20,20 +20,16 @@
|
||||
"@sentry/vue": "^8.27.0",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||
"@tauri-apps/plugin-http": "^2.5.0",
|
||||
"@tauri-apps/plugin-opener": "^2.2.6",
|
||||
"@tauri-apps/plugin-os": "^2.2.1",
|
||||
"@tauri-apps/plugin-updater": "^2.7.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.2.2",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"floating-vue": "^5.2.2",
|
||||
"ofetch": "^1.3.4",
|
||||
"pinia": "^2.1.7",
|
||||
"posthog-js": "^1.158.2",
|
||||
"three": "^0.172.0",
|
||||
"vite-svg-loader": "^5.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-multiselect": "3.0.0",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowBigUpDashIcon,
|
||||
ChangeSkinIcon,
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
HomeIcon,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
SettingsIcon,
|
||||
WorldIcon,
|
||||
XIcon,
|
||||
NewspaperIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
@@ -27,7 +25,7 @@ import {
|
||||
ButtonStyled,
|
||||
Notifications,
|
||||
OverflowMenu,
|
||||
NewsArticleCard,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import { useLoading, useTheming } from '@/store/state'
|
||||
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
||||
@@ -64,13 +62,14 @@ import NavButton from '@/components/ui/NavButton.vue'
|
||||
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||
import { get_user } from '@/helpers/cache.js'
|
||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
import dayjs from 'dayjs'
|
||||
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
|
||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
import { get_available_capes, get_available_skins } from './helpers/skins'
|
||||
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -178,7 +177,6 @@ async function setupApp() {
|
||||
'criticalAnnouncements',
|
||||
true,
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((res) => {
|
||||
if (res && res.header && res.body) {
|
||||
criticalErrorMessage.value = res
|
||||
@@ -190,35 +188,15 @@ async function setupApp() {
|
||||
)
|
||||
})
|
||||
|
||||
useFetch(`https://modrinth.com/news/feed/articles.json`, 'news', true)
|
||||
.then((response) => response.json())
|
||||
.then((res) => {
|
||||
if (res && res.articles) {
|
||||
// Format expected by NewsArticleCard component.
|
||||
news.value = res.articles
|
||||
.map((article) => ({
|
||||
...article,
|
||||
path: article.link,
|
||||
thumbnail: article.thumbnail,
|
||||
title: article.title,
|
||||
summary: article.summary,
|
||||
date: article.date,
|
||||
}))
|
||||
.slice(0, 4)
|
||||
}
|
||||
})
|
||||
useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => {
|
||||
if (res && res.articles) {
|
||||
news.value = res.articles
|
||||
}
|
||||
})
|
||||
|
||||
get_opening_command().then(handleCommand)
|
||||
checkUpdates()
|
||||
fetchCredentials()
|
||||
|
||||
try {
|
||||
const skins = (await get_available_skins()) ?? []
|
||||
const capes = (await get_available_capes()) ?? []
|
||||
generateSkinPreviews(skins, capes)
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate skin previews in app setup.', error)
|
||||
}
|
||||
}
|
||||
|
||||
const stateFailed = ref(false)
|
||||
@@ -326,7 +304,6 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const accounts = ref(null)
|
||||
provide('accountsCard', accounts)
|
||||
|
||||
command_listener(handleCommand)
|
||||
async function handleCommand(e) {
|
||||
@@ -422,9 +399,6 @@ function handleAuxClick(e) {
|
||||
>
|
||||
<CompassIcon />
|
||||
</NavButton>
|
||||
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
|
||||
<ChangeSkinIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="'Library'"
|
||||
to="/library"
|
||||
@@ -605,20 +579,34 @@ function handleAuxClick(e) {
|
||||
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
|
||||
</suspense>
|
||||
</div>
|
||||
<div v-if="news && news.length > 0" class="pt-4 flex flex-col items-center">
|
||||
<h3 class="px-4 text-lg m-0 text-left w-full">News</h3>
|
||||
<div class="px-4 pt-2 space-y-4 flex flex-col items-center w-full">
|
||||
<NewsArticleCard
|
||||
v-for="(item, index) in news"
|
||||
:key="`news-${index}`"
|
||||
:article="item"
|
||||
<div v-if="news && news.length > 0" class="pt-4 flex flex-col">
|
||||
<h3 class="px-4 text-lg m-0">News</h3>
|
||||
<template v-for="(item, index) in news" :key="`news-${index}`">
|
||||
<a
|
||||
:class="`flex flex-col outline-offset-[-4px] hover:bg-[--brand-gradient-border] focus:bg-[--brand-gradient-border] px-4 transition-colors ${index === 0 ? 'pt-2 pb-4' : 'py-4'}`"
|
||||
:href="item.link"
|
||||
target="_blank"
|
||||
rel="external"
|
||||
>
|
||||
<img
|
||||
:src="item.thumbnail"
|
||||
alt="News thumbnail"
|
||||
aria-hidden="true"
|
||||
class="w-full aspect-[3/1] object-cover rounded-2xl border-[1px] border-solid border-[--brand-gradient-border]"
|
||||
/>
|
||||
<h4 class="mt-2 mb-0 text-sm leading-none text-contrast font-semibold">
|
||||
{{ item.title }}
|
||||
</h4>
|
||||
<p class="my-1 text-sm text-secondary leading-tight">{{ item.summary }}</p>
|
||||
<p class="text-right text-sm text-secondary opacity-60 leading-tight m-0">
|
||||
{{ formatRelativeTime(dayjs(item.date).toISOString()) }}
|
||||
</p>
|
||||
</a>
|
||||
<hr
|
||||
v-if="index !== news.length - 1"
|
||||
class="h-px my-[-2px] mx-4 border-0 m-0 bg-[--brand-gradient-border]"
|
||||
/>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<a href="https://modrinth.com/news" target="_blank" class="my-4">
|
||||
<NewspaperIcon /> View all news
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -9,11 +9,13 @@
|
||||
<Avatar
|
||||
size="36px"
|
||||
:src="
|
||||
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
selectedAccount
|
||||
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
|
||||
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
"
|
||||
/>
|
||||
<div class="flex flex-col w-full">
|
||||
<span>{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}</span>
|
||||
<span>{{ selectedAccount ? selectedAccount.username : 'Select account' }}</span>
|
||||
<span class="text-secondary text-xs">Minecraft account</span>
|
||||
</div>
|
||||
<DropdownIcon class="w-5 h-5 shrink-0" />
|
||||
@@ -26,40 +28,28 @@
|
||||
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
|
||||
>
|
||||
<div v-if="selectedAccount" class="selected account">
|
||||
<Avatar size="xs" :src="avatarUrl" />
|
||||
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.id}/128`" />
|
||||
<div>
|
||||
<h4>{{ selectedAccount.profile.name }}</h4>
|
||||
<h4>{{ selectedAccount.username }}</h4>
|
||||
<p>Selected</p>
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="'Log out'"
|
||||
icon-only
|
||||
color="raised"
|
||||
@click="logout(selectedAccount.profile.id)"
|
||||
>
|
||||
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.id)">
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="logged-out account">
|
||||
<h4>Not signed in</h4>
|
||||
<Button
|
||||
v-tooltip="'Log in'"
|
||||
:disabled="loginDisabled"
|
||||
icon-only
|
||||
color="primary"
|
||||
@click="login()"
|
||||
>
|
||||
<LogInIcon v-if="!loginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
<Button v-tooltip="'Log in'" icon-only color="primary" @click="login()">
|
||||
<LogInIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="displayAccounts.length > 0" class="account-group">
|
||||
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
|
||||
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
|
||||
<Button class="option account" @click="setAccount(account)">
|
||||
<Avatar :src="getAccountAvatarUrl(account)" class="icon" />
|
||||
<p>{{ account.profile.name }}</p>
|
||||
<Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
|
||||
<p>{{ account.username }}</p>
|
||||
</Button>
|
||||
<Button v-tooltip="'Log out'" icon-only @click="logout(account.profile.id)">
|
||||
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -73,7 +63,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon } from '@modrinth/assets'
|
||||
import { Avatar, Button, Card } from '@modrinth/ui'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||
import {
|
||||
@@ -87,8 +77,6 @@ import { handleError } from '@/store/state.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { get_available_skins } from '@/helpers/skins'
|
||||
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
|
||||
defineProps({
|
||||
mode: {
|
||||
@@ -101,86 +89,32 @@ defineProps({
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const accounts = ref({})
|
||||
const loginDisabled = ref(false)
|
||||
const defaultUser = ref()
|
||||
const equippedSkin = ref(null)
|
||||
const headUrlCache = ref(new Map())
|
||||
|
||||
async function refreshValues() {
|
||||
defaultUser.value = await get_default_user().catch(handleError)
|
||||
accounts.value = await users().catch(handleError)
|
||||
|
||||
try {
|
||||
const skins = await get_available_skins()
|
||||
equippedSkin.value = skins.find((skin) => skin.is_equipped)
|
||||
|
||||
if (equippedSkin.value) {
|
||||
try {
|
||||
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
|
||||
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
|
||||
} catch (error) {
|
||||
console.warn('Failed to get head render for equipped skin:', error)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
equippedSkin.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function setLoginDisabled(value) {
|
||||
loginDisabled.value = value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refreshValues,
|
||||
setLoginDisabled,
|
||||
loginDisabled,
|
||||
})
|
||||
await refreshValues()
|
||||
|
||||
const displayAccounts = computed(() =>
|
||||
accounts.value.filter((account) => defaultUser.value !== account.profile.id),
|
||||
accounts.value.filter((account) => defaultUser.value !== account.id),
|
||||
)
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
if (equippedSkin.value?.texture_key) {
|
||||
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
|
||||
if (cachedUrl) {
|
||||
return cachedUrl
|
||||
}
|
||||
return `https://mc-heads.net/avatar/${equippedSkin.value.texture_key}/128`
|
||||
}
|
||||
if (selectedAccount.value?.profile?.id) {
|
||||
return `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128`
|
||||
}
|
||||
return 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
})
|
||||
|
||||
function getAccountAvatarUrl(account) {
|
||||
if (
|
||||
account.profile.id === selectedAccount.value?.profile?.id &&
|
||||
equippedSkin.value?.texture_key
|
||||
) {
|
||||
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
|
||||
if (cachedUrl) {
|
||||
return cachedUrl
|
||||
}
|
||||
}
|
||||
return `https://mc-heads.net/avatar/${account.profile.id}/128`
|
||||
}
|
||||
|
||||
const selectedAccount = computed(() =>
|
||||
accounts.value.find((account) => account.profile.id === defaultUser.value),
|
||||
accounts.value.find((account) => account.id === defaultUser.value),
|
||||
)
|
||||
|
||||
async function setAccount(account) {
|
||||
defaultUser.value = account.profile.id
|
||||
await set_default_user(account.profile.id).catch(handleError)
|
||||
defaultUser.value = account.id
|
||||
await set_default_user(account.id).catch(handleError)
|
||||
emit('change')
|
||||
}
|
||||
|
||||
async function login() {
|
||||
loginDisabled.value = true
|
||||
const loggedIn = await login_flow().catch(handleSevereError)
|
||||
|
||||
if (loggedIn) {
|
||||
@@ -189,7 +123,6 @@ async function login() {
|
||||
}
|
||||
|
||||
trackEvent('AccountLogIn')
|
||||
loginDisabled.value = false
|
||||
}
|
||||
|
||||
const logout = async (id) => {
|
||||
|
||||
@@ -92,7 +92,7 @@ async function loginMinecraft() {
|
||||
const loggedIn = await login_flow()
|
||||
|
||||
if (loggedIn) {
|
||||
await set_default_user(loggedIn.profile.id).catch(handleError)
|
||||
await set_default_user(loggedIn.id).catch(handleError)
|
||||
}
|
||||
|
||||
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
||||
@@ -219,8 +219,8 @@ async function copyToClipboard(text) {
|
||||
<template v-else-if="metadata.notEnoughSpace">
|
||||
<h3>Not enough space</h3>
|
||||
<p>
|
||||
It looks like there is not enough space on the disk containing the directory you
|
||||
selected. Please free up some space and try again or cancel the directory change.
|
||||
It looks like there is not enough space on the disk containing the dirctory you
|
||||
selected Please free up some space and try again or cancel the directory change.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { showProfileInFolder } from '@/helpers/utils.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import dayjs from 'dayjs'
|
||||
import { formatCategory } from '@modrinth/utils'
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
@@ -172,10 +173,7 @@ onUnmounted(() => unlisten())
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
|
||||
<TimerIcon />
|
||||
<span class="text-sm">
|
||||
<template v-if="instance.last_played">
|
||||
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,8 +237,8 @@ onUnmounted(() => unlisten())
|
||||
</p>
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
|
||||
<GameIcon class="shrink-0" />
|
||||
<span class="text-sm capitalize">
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
<span class="text-sm">
|
||||
{{ formatCategory(instance.loader) }} {{ instance.game_version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -127,7 +127,7 @@ async function handleJavaFileInput() {
|
||||
const filePath = await open()
|
||||
|
||||
if (filePath) {
|
||||
let result = await get_jre(filePath.path ?? filePath).catch(handleError)
|
||||
let result = await get_jre(filePath.path ?? filePath)
|
||||
if (!result) {
|
||||
result = {
|
||||
path: filePath.path ?? filePath,
|
||||
|
||||
@@ -32,33 +32,8 @@ function updateAdPosition() {
|
||||
|
||||
<template>
|
||||
<div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
|
||||
<a
|
||||
href="https://modrinth.gg?from=app-placeholder"
|
||||
target="_blank"
|
||||
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
|
||||
>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
class="hidden light-image rounded-[inherit]"
|
||||
/>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
class="dark-image rounded-[inherit]"
|
||||
/>
|
||||
</a>
|
||||
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
|
||||
<p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.light,
|
||||
.light-mode {
|
||||
.dark-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.light-image {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,7 +30,7 @@ const getInstances = async () => {
|
||||
|
||||
return dateB - dateA
|
||||
})
|
||||
.slice(0, 3)
|
||||
.slice(0, 4)
|
||||
}
|
||||
|
||||
await getInstances()
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ShieldIcon,
|
||||
SettingsIcon,
|
||||
GaugeIcon,
|
||||
PaintbrushIcon,
|
||||
PaintBrushIcon,
|
||||
GameIcon,
|
||||
CoffeeIcon,
|
||||
} from '@modrinth/assets'
|
||||
@@ -41,7 +41,7 @@ const tabs = [
|
||||
id: 'app.settings.tabs.appearance',
|
||||
defaultMessage: 'Appearance',
|
||||
}),
|
||||
icon: PaintbrushIcon,
|
||||
icon: PaintBrushIcon,
|
||||
content: AppearanceSettings,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { NewModal as Modal } from '@modrinth/ui'
|
||||
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
|
||||
import { useTheming } from '@/store/theme.ts'
|
||||
@@ -26,16 +26,16 @@ const props = defineProps({
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
const modal = useTemplateRef('modal')
|
||||
const modal = ref(null)
|
||||
|
||||
defineExpose({
|
||||
show: (e: MouseEvent) => {
|
||||
show: () => {
|
||||
hide_ads_window()
|
||||
modal.value?.show(e)
|
||||
modal.value.show()
|
||||
},
|
||||
hide: () => {
|
||||
onModalHide()
|
||||
modal.value?.hide()
|
||||
modal.value.hide()
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -56,17 +56,9 @@ watch(
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2>
|
||||
<p class="m-0 mt-1">Disables the nametag above your player on the skins page.</p>
|
||||
</div>
|
||||
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
|
||||
</div>
|
||||
|
||||
<div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Native decorations</h2>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Native Decorations</h2>
|
||||
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
|
||||
</div>
|
||||
<Toggle id="native-decorations" v-model="settings.native_decorations" />
|
||||
|
||||
@@ -1,412 +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,
|
||||
} 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 = 'CLASSIC'
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
|
||||
await loadPreviewSkin()
|
||||
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
async function restoreWithNewTexture(skinTextureUrl: string) {
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
await loadPreviewSkin()
|
||||
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
setTimeout(() => resetState(), 250)
|
||||
}
|
||||
|
||||
function selectCape(cape: Cape | undefined) {
|
||||
if (cape && selectedCape.value?.id !== cape.id) {
|
||||
const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id)
|
||||
if (!isInVisibleList && visibleCapeList.value.length > 0) {
|
||||
visibleCapeList.value.splice(0, 1, cape)
|
||||
|
||||
if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) {
|
||||
const otherCape = getSortedCapeExcluding(cape.id)
|
||||
if (otherCape) {
|
||||
visibleCapeList.value.splice(1, 1, otherCape)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedCape.value = cape
|
||||
}
|
||||
|
||||
function handleCapeSelected(cape: Cape | undefined) {
|
||||
selectCape(cape)
|
||||
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCapeCancel() {
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function openSelectCapeModal(e: MouseEvent) {
|
||||
if (!selectCapeModal.value) return
|
||||
|
||||
shouldRestoreModal.value = true
|
||||
modal.value?.hide()
|
||||
|
||||
setTimeout(() => {
|
||||
selectCapeModal.value?.show(
|
||||
e,
|
||||
currentSkin.value?.texture_key,
|
||||
selectedCape.value,
|
||||
previewSkin.value,
|
||||
variant.value,
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function openUploadSkinModal(e: MouseEvent) {
|
||||
shouldRestoreModal.value = true
|
||||
modal.value?.hide()
|
||||
emit('open-upload-modal', e)
|
||||
}
|
||||
|
||||
function restoreModal() {
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
modal.value?.show(fakeEvent)
|
||||
shouldRestoreModal.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
let textureUrl: string
|
||||
|
||||
if (uploadedTextureUrl.value) {
|
||||
textureUrl = uploadedTextureUrl.value
|
||||
} else {
|
||||
textureUrl = currentSkin.value!.texture
|
||||
}
|
||||
|
||||
await unequip_skin()
|
||||
|
||||
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
|
||||
|
||||
if (mode.value === 'new') {
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
emit('saved')
|
||||
} else {
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
await remove_custom_skin(currentSkin.value!)
|
||||
emit('saved')
|
||||
}
|
||||
|
||||
hide()
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([uploadedTextureUrl, currentSkin], async () => {
|
||||
await loadPreviewSkin()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.capes,
|
||||
() => {
|
||||
initVisibleCapeList()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'saved'): void
|
||||
(event: 'deleted', skin: Skin): void
|
||||
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
|
||||
}>()
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
showNew,
|
||||
restoreWithNewTexture,
|
||||
hide,
|
||||
shouldRestoreModal,
|
||||
restoreModal,
|
||||
})
|
||||
</script>
|
||||
@@ -1,140 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef, ref, computed } from 'vue'
|
||||
import type { Cape, SkinModel } from '@/helpers/skins.ts'
|
||||
import {
|
||||
ButtonStyled,
|
||||
ScrollablePanel,
|
||||
CapeButton,
|
||||
CapeLikeTextButton,
|
||||
SkinPreviewRenderer,
|
||||
} from '@modrinth/ui'
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', cape: Cape | undefined): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
capes: Cape[]
|
||||
}>()
|
||||
|
||||
const sortedCapes = computed(() => {
|
||||
return [...props.capes].sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase()
|
||||
const nameB = (b.name || '').toLowerCase()
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
})
|
||||
|
||||
const currentSkinId = ref<string | undefined>()
|
||||
const currentSkinTexture = ref<string | undefined>()
|
||||
const currentSkinVariant = ref<SkinModel>('CLASSIC')
|
||||
const currentCapeTexture = computed<string | undefined>(() => currentCape.value?.texture)
|
||||
const currentCape = ref<Cape | undefined>()
|
||||
|
||||
function show(
|
||||
e: MouseEvent,
|
||||
skinId?: string,
|
||||
selected?: Cape,
|
||||
skinTexture?: string,
|
||||
variant?: SkinModel,
|
||||
) {
|
||||
currentSkinId.value = skinId
|
||||
currentSkinTexture.value = skinTexture
|
||||
currentSkinVariant.value = variant || 'CLASSIC'
|
||||
currentCape.value = selected
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function select() {
|
||||
emit('select', currentCape.value)
|
||||
hide()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function updateSelectedCape(cape: Cape | undefined) {
|
||||
currentCape.value = cape
|
||||
}
|
||||
|
||||
function onModalHide() {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @on-hide="onModalHide">
|
||||
<template #title>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg font-extrabold text-heading">Change cape</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="max-h-[25rem] h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
|
||||
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
|
||||
<SkinPreviewRenderer
|
||||
v-if="currentSkinTexture"
|
||||
:cape-src="currentCapeTexture"
|
||||
:texture-src="currentSkinTexture"
|
||||
:variant="currentSkinVariant"
|
||||
:scale="1.4"
|
||||
:fov="50"
|
||||
:initial-rotation="Math.PI + Math.PI / 8"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full my-auto">
|
||||
<ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full">
|
||||
<div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full">
|
||||
<CapeLikeTextButton
|
||||
tooltip="No Cape"
|
||||
:highlighted="!currentCape"
|
||||
@click="updateSelectedCape(undefined)"
|
||||
>
|
||||
<template #icon>
|
||||
<XIcon />
|
||||
</template>
|
||||
<span>None</span>
|
||||
</CapeLikeTextButton>
|
||||
<CapeButton
|
||||
v-for="cape in sortedCapes"
|
||||
:id="cape.id"
|
||||
:key="cape.id"
|
||||
:name="cape.name"
|
||||
:texture="cape.texture"
|
||||
:selected="currentCape?.id === cape.id"
|
||||
@select="updateSelectedCape(cape)"
|
||||
/>
|
||||
</div>
|
||||
</ScrollablePanel>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="select">
|
||||
<CheckIcon />
|
||||
Select
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@@ -1,140 +0,0 @@
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @on-hide="hide(true)">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast"> Upload skin texture </span>
|
||||
</template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2">
|
||||
<UploadIcon /> Select skin texture file
|
||||
</p>
|
||||
<p class="mx-auto mt-0 text-secondary text-sm text-center">
|
||||
Drag and drop or click here to browse
|
||||
</p>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png"
|
||||
class="hidden"
|
||||
@change="handleInputFileChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount, watch } from 'vue'
|
||||
import { UploadIcon } from '@modrinth/assets'
|
||||
import { useNotifications } from '@/store/state'
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get_dragged_skin_data } from '@/helpers/skins'
|
||||
|
||||
const notifications = useNotifications()
|
||||
|
||||
const modal = ref()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const unlisten = ref<() => void>()
|
||||
const modalVisible = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'uploaded', data: ArrayBuffer): void
|
||||
(e: 'canceled'): void
|
||||
}>()
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
modal.value?.show(e)
|
||||
modalVisible.value = true
|
||||
setupDragDropListener()
|
||||
}
|
||||
|
||||
function hide(emitCanceled = false) {
|
||||
modal.value?.hide()
|
||||
modalVisible.value = false
|
||||
cleanupDragDropListener()
|
||||
resetState()
|
||||
if (emitCanceled) {
|
||||
emit('canceled')
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleInputFileChange(e: Event) {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
if (!files || files.length === 0) {
|
||||
return
|
||||
}
|
||||
const file = files[0]
|
||||
const buffer = await file.arrayBuffer()
|
||||
await processData(buffer)
|
||||
}
|
||||
|
||||
async function setupDragDropListener() {
|
||||
try {
|
||||
if (modalVisible.value) {
|
||||
await cleanupDragDropListener()
|
||||
unlisten.value = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
if (event.payload.type !== 'drop') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.payload.paths || event.payload.paths.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = event.payload.paths[0]
|
||||
|
||||
try {
|
||||
const data = await get_dragged_skin_data(filePath)
|
||||
await processData(data.buffer)
|
||||
} catch (error) {
|
||||
notifications.addNotification({
|
||||
title: 'Error processing file',
|
||||
text: error instanceof Error ? error.message : 'Failed to read the dropped file.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set up drag and drop listener:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupDragDropListener() {
|
||||
if (unlisten.value) {
|
||||
unlisten.value()
|
||||
unlisten.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function processData(buffer: ArrayBuffer) {
|
||||
emit('uploaded', buffer)
|
||||
hide()
|
||||
}
|
||||
|
||||
watch(modalVisible, (isVisible) => {
|
||||
if (isVisible) {
|
||||
setupDragDropListener()
|
||||
} else {
|
||||
cleanupDragDropListener()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDragDropListener()
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
EyeIcon,
|
||||
@@ -43,7 +42,6 @@ const emit = defineEmits<{
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
last_played: Dayjs
|
||||
}>()
|
||||
|
||||
const loadingModpack = ref(!!props.instance.linked_data)
|
||||
@@ -149,12 +147,12 @@ onUnmounted(() => {
|
||||
: null
|
||||
"
|
||||
class="w-fit shrink-0"
|
||||
:class="{ 'cursor-help smart-clickable:allow-pointer-events': last_played }"
|
||||
:class="{ 'cursor-help smart-clickable:allow-pointer-events': instance.last_played }"
|
||||
>
|
||||
<template v-if="last_played">
|
||||
<template v-if="instance.last_played">
|
||||
{{
|
||||
formatMessage(commonMessages.playedLabel, {
|
||||
time: formatRelativeTime(last_played.toISOString?.()),
|
||||
time: formatRelativeTime(instance.last_played.toISOString()),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
|
||||
@@ -84,7 +84,7 @@ async function populateJumpBackIn() {
|
||||
|
||||
worldItems.push({
|
||||
type: 'world',
|
||||
last_played: dayjs(world.last_played ?? 0),
|
||||
last_played: dayjs(world.last_played),
|
||||
world: world,
|
||||
instance: instance,
|
||||
})
|
||||
@@ -138,13 +138,13 @@ async function populateJumpBackIn() {
|
||||
|
||||
instanceItems.push({
|
||||
type: 'instance',
|
||||
last_played: dayjs(instance.last_played ?? 0),
|
||||
last_played: dayjs(instance.last_played),
|
||||
instance: instance,
|
||||
})
|
||||
}
|
||||
|
||||
const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
|
||||
items.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0)))
|
||||
items.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played)))
|
||||
jumpBackInItems.value = items
|
||||
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO))
|
||||
.slice(0, MAX_JUMP_BACK_IN)
|
||||
@@ -291,7 +291,7 @@ onUnmounted(() => {
|
||||
"
|
||||
@stop="() => stopInstance(item.instance.path)"
|
||||
/>
|
||||
<InstanceItem v-else :instance="item.instance" :last_played="item.last_played" />
|
||||
<InstanceItem v-else :instance="item.instance" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { fetch } from '@tauri-apps/plugin-http'
|
||||
import { ofetch } from 'ofetch'
|
||||
import { handleError } from '@/store/state.js'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
|
||||
export const useFetch = async (url, item, isSilent) => {
|
||||
try {
|
||||
const version = await getVersion()
|
||||
return await fetch(url, {
|
||||
method: 'GET',
|
||||
|
||||
return await ofetch(url, {
|
||||
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,354 +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 } from '@modrinth/utils'
|
||||
import { skinPreviewStorage } from '../storage/skin-preview-storage'
|
||||
import { CapeModel, ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
|
||||
|
||||
export interface RenderResult {
|
||||
forwards: string
|
||||
backwards: string
|
||||
}
|
||||
|
||||
class BatchSkinRenderer {
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private readonly scene: THREE.Scene
|
||||
private readonly camera: THREE.PerspectiveCamera
|
||||
private currentModel: THREE.Group | null = null
|
||||
|
||||
constructor(width: number = 360, height: number = 504) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = 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(width, height)
|
||||
|
||||
this.scene = new THREE.Scene()
|
||||
this.camera = new THREE.PerspectiveCamera(20, width / 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,
|
||||
capeModelUrl?: string,
|
||||
): Promise<RenderResult> {
|
||||
await this.setupModel(modelUrl, textureUrl, capeModelUrl, 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<string> {
|
||||
this.camera.position.set(...cameraPosition)
|
||||
this.camera.lookAt(...lookAtPosition)
|
||||
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
this.renderer.domElement.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
resolve(url)
|
||||
} else {
|
||||
reject(new Error('Failed to create blob from canvas'))
|
||||
}
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
private async setupModel(
|
||||
modelUrl: string,
|
||||
textureUrl: string,
|
||||
capeModelUrl?: string,
|
||||
capeUrl?: string,
|
||||
): Promise<void> {
|
||||
if (this.currentModel) {
|
||||
this.scene.remove(this.currentModel)
|
||||
}
|
||||
|
||||
const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.renderer.dispose()
|
||||
disposeCaches()
|
||||
}
|
||||
}
|
||||
|
||||
function getModelUrlForVariant(variant: string): string {
|
||||
switch (variant) {
|
||||
case 'SLIM':
|
||||
return SlimPlayerModel
|
||||
case 'CLASSIC':
|
||||
case 'UNKNOWN':
|
||||
default:
|
||||
return ClassicPlayerModel
|
||||
}
|
||||
}
|
||||
|
||||
export const map = reactive(new Map<string, RenderResult>())
|
||||
export const headMap = reactive(new Map<string, string>())
|
||||
const DEBUG_MODE = false
|
||||
|
||||
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 skinPreviewStorage.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/png')
|
||||
} 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 (headMap.has(headKey)) {
|
||||
if (DEBUG_MODE) {
|
||||
const url = headMap.get(headKey)!
|
||||
URL.revokeObjectURL(url)
|
||||
headMap.delete(headKey)
|
||||
} else {
|
||||
return headMap.get(headKey)!
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const cached = await skinPreviewStorage.retrieve(headKey)
|
||||
if (cached && typeof cached === 'string') {
|
||||
headMap.set(headKey, cached)
|
||||
return cached
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to retrieve cached head render:', error)
|
||||
}
|
||||
|
||||
const skinUrl = await get_normalized_skin_texture(skin)
|
||||
const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
|
||||
const headUrl = URL.createObjectURL(headBlob)
|
||||
|
||||
headMap.set(headKey, headUrl)
|
||||
|
||||
try {
|
||||
// @ts-expect-error - skinPreviewStorage.store expects a RenderResult, but we are storing a string url.
|
||||
await skinPreviewStorage.store(headKey, headUrl)
|
||||
} 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> {
|
||||
const renderer = new BatchSkinRenderer()
|
||||
|
||||
try {
|
||||
for (const skin of skins) {
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
|
||||
if (map.has(key)) {
|
||||
if (DEBUG_MODE) {
|
||||
const result = map.get(key)!
|
||||
URL.revokeObjectURL(result.forwards)
|
||||
URL.revokeObjectURL(result.backwards)
|
||||
map.delete(key)
|
||||
} else continue
|
||||
}
|
||||
|
||||
try {
|
||||
const cached = await skinPreviewStorage.retrieve(key)
|
||||
if (cached) {
|
||||
map.set(key, cached)
|
||||
continue
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to retrieve cached skin preview:', error)
|
||||
}
|
||||
|
||||
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 renderResult = await renderer.renderSkin(
|
||||
await get_normalized_skin_texture(skin),
|
||||
modelUrl,
|
||||
cape?.texture,
|
||||
CapeModel,
|
||||
)
|
||||
|
||||
map.set(key, renderResult)
|
||||
|
||||
try {
|
||||
await skinPreviewStorage.store(key, renderResult)
|
||||
} catch (error) {
|
||||
console.warn('Failed to store skin preview in persistent storage:', error)
|
||||
}
|
||||
|
||||
await generateHeadRender(skin)
|
||||
}
|
||||
} finally {
|
||||
renderer.dispose()
|
||||
await cleanupUnusedPreviews(skins)
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,6 @@ export type AppSettings = {
|
||||
theme: ColorTheme
|
||||
default_page: 'home' | 'library'
|
||||
collapsed_navigation: boolean
|
||||
hide_nametag_skins_page: boolean
|
||||
advanced_rendering: boolean
|
||||
native_decorations: boolean
|
||||
toggle_sidebar: boolean
|
||||
|
||||
@@ -1,167 +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 = 44
|
||||
const armY = 16
|
||||
const armWidth = 4
|
||||
const armHeight = 12
|
||||
|
||||
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
|
||||
|
||||
for (let y = 0; y < armHeight; y++) {
|
||||
const alphaIndex = (3 + y * armWidth) * 4 + 3
|
||||
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,118 +0,0 @@
|
||||
import type { RenderResult } 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: RenderResult): Promise<void> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const forwardsBlob = await fetch(result.forwards).then((r) => r.blob())
|
||||
const backwardsBlob = await fetch(result.backwards).then((r) => r.blob())
|
||||
|
||||
const transaction = this.db!.transaction(['previews'], 'readwrite')
|
||||
const store = transaction.objectStore('previews')
|
||||
|
||||
const storedPreview: StoredPreview = {
|
||||
forwards: forwardsBlob,
|
||||
backwards: backwardsBlob,
|
||||
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<RenderResult | 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
|
||||
}
|
||||
|
||||
const forwards = URL.createObjectURL(result.forwards)
|
||||
const backwards = URL.createObjectURL(result.backwards)
|
||||
resolve({ forwards, backwards })
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const skinPreviewStorage = new SkinPreviewStorage()
|
||||
@@ -220,7 +220,6 @@ async function refreshSearch() {
|
||||
}
|
||||
}
|
||||
results.value = rawResults.result
|
||||
currentPage.value = Math.max(1, Math.min(pageCount.value, currentPage.value))
|
||||
|
||||
const persistentParams: LocationQuery = {}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import dayjs from 'dayjs'
|
||||
import { get_search_results } from '@/helpers/cache.js'
|
||||
import type { SearchResult } from '@modrinth/utils'
|
||||
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const route = useRoute()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
@@ -83,15 +82,13 @@ async function refreshFeaturedProjects() {
|
||||
await fetchInstances()
|
||||
await refreshFeaturedProjects()
|
||||
|
||||
const unlistenProfile = await profile_listener(
|
||||
async (e: { event: string; profile_path_id: string }) => {
|
||||
await fetchInstances()
|
||||
const unlistenProfile = await profile_listener(async (e) => {
|
||||
await fetchInstances()
|
||||
|
||||
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
||||
await refreshFeaturedProjects()
|
||||
}
|
||||
},
|
||||
)
|
||||
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
||||
await refreshFeaturedProjects()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProfile()
|
||||
@@ -100,8 +97,8 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="p-6 flex flex-col gap-2">
|
||||
<h1 v-if="recentInstances?.length > 0" class="m-0 text-2xl font-extrabold">Welcome back!</h1>
|
||||
<h1 v-else class="m-0 text-2xl font-extrabold">Welcome to Modrinth App!</h1>
|
||||
<h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
|
||||
<h1 v-else class="m-0 text-2xl">Welcome to Modrinth App!</h1>
|
||||
<RecentWorldsList :recent-instances="recentInstances" />
|
||||
<RowDisplay
|
||||
v-if="hasFeaturedProjects"
|
||||
|
||||
@@ -1,521 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
EditIcon,
|
||||
ExcitedRinthbot,
|
||||
LogInIcon,
|
||||
PlusIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
UpdatedIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
ButtonStyled,
|
||||
ConfirmModal,
|
||||
SkinButton,
|
||||
SkinLikeTextButton,
|
||||
SkinPreviewRenderer,
|
||||
} from '@modrinth/ui'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
|
||||
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
|
||||
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
|
||||
import { handleError, useNotifications } from '@/store/notifications'
|
||||
import type { Cape, Skin } from '@/helpers/skins.ts'
|
||||
import {
|
||||
normalize_skin_texture,
|
||||
equip_skin,
|
||||
filterDefaultSkins,
|
||||
filterSavedSkins,
|
||||
get_available_capes,
|
||||
get_available_skins,
|
||||
get_normalized_skin_texture,
|
||||
remove_custom_skin,
|
||||
set_default_cape,
|
||||
} from '@/helpers/skins.ts'
|
||||
import { get as getSettings } from '@/helpers/settings.ts'
|
||||
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
|
||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { generateSkinPreviews, map } 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 map.get(key)
|
||||
}
|
||||
|
||||
async function login() {
|
||||
accountsCard.value.setLoginDisabled(true)
|
||||
const loggedIn = await login_flow().catch(handleSevereError)
|
||||
|
||||
if (loggedIn && accountsCard) {
|
||||
await accountsCard.value.refreshValues()
|
||||
}
|
||||
|
||||
trackEvent('AccountLogIn')
|
||||
accountsCard.value.setLoginDisabled(false)
|
||||
}
|
||||
|
||||
function openUploadSkinModal(e: MouseEvent) {
|
||||
uploadSkinModal.value?.show(e)
|
||||
}
|
||||
|
||||
function onSkinFileUploaded(buffer: ArrayBuffer) {
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
normalize_skin_texture(`data:image/png;base64,` + arrayBufferToBase64(buffer)).then(
|
||||
(skinTextureNormalized: Uint8Array) => {
|
||||
const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized)
|
||||
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
|
||||
editSkinModal.value.restoreWithNewTexture(skinTexUrl)
|
||||
} else {
|
||||
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function onUploadCanceled() {
|
||||
editSkinModal.value?.restoreModal()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selectedSkin.value?.cape_id,
|
||||
() => {},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
userCheckInterval = window.setInterval(checkUserChanges, 250)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (userCheckInterval !== null) {
|
||||
window.clearInterval(userCheckInterval)
|
||||
}
|
||||
})
|
||||
|
||||
async function checkUserChanges() {
|
||||
try {
|
||||
const defaultId = await get_default_user()
|
||||
if (defaultId !== currentUserId.value) {
|
||||
await loadCurrentUser()
|
||||
await loadCapes()
|
||||
await loadSkins()
|
||||
}
|
||||
} catch (error) {
|
||||
if (currentUser.value) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EditSkinModal
|
||||
ref="editSkinModal"
|
||||
:capes="capes"
|
||||
:default-cape="defaultCape"
|
||||
@saved="onSkinSaved"
|
||||
@deleted="() => loadSkins()"
|
||||
@open-upload-modal="openUploadSkinModal"
|
||||
/>
|
||||
<SelectCapeModal ref="selectCapeModal" :capes="capes" @select="handleCapeSelected" />
|
||||
<UploadSkinModal
|
||||
ref="uploadSkinModal"
|
||||
@uploaded="onSkinFileUploaded"
|
||||
@canceled="onUploadCanceled"
|
||||
/>
|
||||
<ConfirmModal
|
||||
ref="deleteSkinModal"
|
||||
title="Are you sure you want to delete this skin?"
|
||||
description="This will permanently delete the selected skin. This action cannot be undone."
|
||||
proceed-label="Delete"
|
||||
@proceed="deleteSkin"
|
||||
/>
|
||||
|
||||
<div v-if="currentUser" class="p-4 skin-layout">
|
||||
<div class="preview-panel">
|
||||
<h1 class="m-0 text-2xl font-bold flex items-center gap-2">
|
||||
Skins
|
||||
<span class="text-sm font-bold px-2 bg-brand-highlight text-brand rounded-full">Beta</span>
|
||||
</h1>
|
||||
<div class="preview-container">
|
||||
<SkinPreviewRenderer
|
||||
:cape-src="capeTexture"
|
||||
:texture-src="skinTexture || ''"
|
||||
:variant="skinVariant"
|
||||
:nametag="skinNametag"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
>
|
||||
<template #subtitle>
|
||||
<ButtonStyled :disabled="!!selectedSkin?.cape_id">
|
||||
<button
|
||||
v-tooltip="
|
||||
selectedSkin?.cape_id
|
||||
? 'The equipped skin is overriding the default cape.'
|
||||
: undefined
|
||||
"
|
||||
:disabled="!!selectedSkin?.cape_id"
|
||||
@click="
|
||||
(e: MouseEvent) =>
|
||||
selectCapeModal?.show(
|
||||
e,
|
||||
selectedSkin?.texture_key,
|
||||
currentCape,
|
||||
skinTexture,
|
||||
skinVariant,
|
||||
)
|
||||
"
|
||||
>
|
||||
<UpdatedIcon />
|
||||
Change cape
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinPreviewRenderer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skins-container">
|
||||
<section class="flex flex-col gap-2 mt-1">
|
||||
<h2 class="text-lg font-bold m-0 text-primary">Saved skins</h2>
|
||||
<div class="skin-card-grid">
|
||||
<SkinLikeTextButton class="skin-card" @click="openUploadSkinModal">
|
||||
<template #icon>
|
||||
<PlusIcon class="size-8" />
|
||||
</template>
|
||||
<span>Add a skin</span>
|
||||
</SkinLikeTextButton>
|
||||
|
||||
<SkinButton
|
||||
v-for="skin in savedSkins"
|
||||
:key="`saved-skin-${skin.texture_key}`"
|
||||
class="skin-card"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="selectedSkin === skin"
|
||||
@select="changeSkin(skin)"
|
||||
>
|
||||
<template #overlay-buttons>
|
||||
<Button
|
||||
color="green"
|
||||
aria-label="Edit skin"
|
||||
class="pointer-events-auto"
|
||||
@click.stop="(e) => editSkinModal?.show(e, skin)"
|
||||
>
|
||||
<EditIcon /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
v-show="!skin.is_equipped"
|
||||
v-tooltip="'Delete skin'"
|
||||
aria-label="Delete skin"
|
||||
color="red"
|
||||
class="!rounded-[100%] pointer-events-auto"
|
||||
icon-only
|
||||
@click.stop="() => confirmDeleteSkin(skin)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2 mt-6">
|
||||
<h2 class="text-lg font-bold m-0 text-primary">Default skins</h2>
|
||||
<div class="skin-card-grid">
|
||||
<SkinButton
|
||||
v-for="skin in defaultSkins"
|
||||
:key="`default-skin-${skin.texture_key}`"
|
||||
class="skin-card"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="selectedSkin === skin"
|
||||
:tooltip="skin.name"
|
||||
@select="changeSkin(skin)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center justify-center min-h-[50vh] pt-[25%]">
|
||||
<div
|
||||
class="bg-bg-raised rounded-lg p-7 flex flex-col gap-5 shadow-md relative max-w-xl w-full mx-auto"
|
||||
>
|
||||
<img
|
||||
:src="ExcitedRinthbot"
|
||||
alt="Excited Modrinth Bot"
|
||||
class="absolute -top-28 right-8 md:right-20 h-28 w-auto"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-[1px] opacity-40 bg-gradient-to-r from-transparent via-green-500 to-transparent"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 2rem,
|
||||
var(--color-green) calc(100% - 13rem),
|
||||
var(--color-green) calc(100% - 5rem),
|
||||
transparent calc(100% - 2rem)
|
||||
);
|
||||
"
|
||||
></div>
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
<h1 class="text-3xl font-extrabold m-0">Please sign-in</h1>
|
||||
<p class="text-lg m-0">
|
||||
Please sign into your Minecraft account to use the skin management features of the
|
||||
Modrinth app.
|
||||
</p>
|
||||
<ButtonStyled v-show="accountsCard" color="brand" :disabled="accountsCard.loginDisabled">
|
||||
<button :disabled="accountsCard.loginDisabled" @click="login">
|
||||
<LogInIcon v-if="!accountsCard.loginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
Sign In
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$skin-card-width: 155px;
|
||||
$skin-card-gap: 4px;
|
||||
|
||||
.skin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2.5fr);
|
||||
gap: 2.5rem;
|
||||
|
||||
@media (max-width: 700px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
top: 1.5rem;
|
||||
position: sticky;
|
||||
align-self: start;
|
||||
padding: 0.5rem;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
height: 80vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: calc((2.5rem / 2));
|
||||
|
||||
@media (max-width: 700px) {
|
||||
height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
.skins-container {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.skin-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: $skin-card-gap;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 1300px) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1750px) {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 2050px) {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.skin-card {
|
||||
aspect-ratio: 0.95;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,5 @@
|
||||
import Index from './Index.vue'
|
||||
import Browse from './Browse.vue'
|
||||
import Worlds from './Worlds.vue'
|
||||
import Skins from './Skins.vue'
|
||||
|
||||
export { Index, Browse, Worlds, Skins }
|
||||
export { Index, Browse, Worlds }
|
||||
|
||||
@@ -34,14 +34,6 @@ export default new createRouter({
|
||||
breadcrumb: [{ name: 'Discover content' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/skins',
|
||||
name: 'Skins',
|
||||
component: Pages.Skins,
|
||||
meta: {
|
||||
breadcrumb: [{ name: 'Skins' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/library',
|
||||
name: 'Library',
|
||||
|
||||
@@ -41,7 +41,6 @@ export default {
|
||||
green: 'var(--color-green-highlight)',
|
||||
blue: 'var(--color-blue-highlight)',
|
||||
purple: 'var(--color-purple-highlight)',
|
||||
gray: 'var(--color-gray-highlight)',
|
||||
},
|
||||
divider: {
|
||||
DEFAULT: 'var(--color-divider)',
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
"strict": true
|
||||
},
|
||||
|
||||
@@ -4,8 +4,6 @@ import svgLoader from 'vite-svg-loader'
|
||||
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
import tauriConf from '../app/tauri.conf.json'
|
||||
|
||||
const projectRootDir = resolve(__dirname)
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
@@ -43,32 +41,17 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
headers: {
|
||||
'content-security-policy': Object.entries(tauriConf.app.security.csp)
|
||||
.map(([directive, sources]) => {
|
||||
// An additional websocket connect-src is required for Vite dev tools to work
|
||||
if (directive === 'connect-src') {
|
||||
sources = Array.isArray(sources) ? sources : [sources]
|
||||
sources.push('ws://localhost:1420')
|
||||
}
|
||||
|
||||
return Array.isArray(sources)
|
||||
? `${directive} ${sources.join(' ')}`
|
||||
: `${directive} ${sources}`
|
||||
})
|
||||
.join('; '),
|
||||
},
|
||||
},
|
||||
// to make use of `TAURI_ENV_DEBUG` and other env variables
|
||||
// https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
build: {
|
||||
// Tauri supports es2021
|
||||
target: process.env.TAURI_ENV_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
// don't minify for debug builds
|
||||
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
// produce sourcemaps for debug builds
|
||||
sourcemap: !!process.env.TAURI_ENV_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
sourcemap: !!process.env.TAURI_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
commonjsOptions: {
|
||||
esmExternals: true,
|
||||
},
|
||||
|
||||
@@ -27,10 +27,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
||||
|
||||
let credentials = minecraft_auth::finish_login(&input, login).await?;
|
||||
|
||||
println!(
|
||||
"Logged in user {}.",
|
||||
credentials.maybe_online_profile().await.name
|
||||
);
|
||||
println!("Logged in user {}.", credentials.username);
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
|
||||
4
apps/app/.gitignore
vendored
4
apps/app/.gitignore
vendored
@@ -1,2 +1,6 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by tauri, metadata generated at compile time
|
||||
/gen/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus_gui"
|
||||
version = "1.0.0-local" # The actual version is set by the theseus-build workflow on tagging
|
||||
version = "0.9.5"
|
||||
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
|
||||
license = "GPL-3.0-only"
|
||||
repository = "https://github.com/modrinth/code/apps/app/"
|
||||
@@ -17,14 +17,13 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_with.workspace = true
|
||||
|
||||
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] }
|
||||
tauri-plugin-deep-link.workspace = true
|
||||
tauri-plugin-dialog.workspace = true
|
||||
tauri-plugin-http.workspace = true
|
||||
tauri-plugin-opener.workspace = true
|
||||
tauri-plugin-os.workspace = true
|
||||
tauri-plugin-single-instance.workspace = true
|
||||
tauri-plugin-updater.workspace = true
|
||||
tauri-plugin-window-state.workspace = true
|
||||
tauri-plugin-deep-link.workspace = true
|
||||
tauri-plugin-os.workspace = true
|
||||
tauri-plugin-opener.workspace = true
|
||||
tauri-plugin-dialog.workspace = true
|
||||
tauri-plugin-updater.workspace = true
|
||||
tauri-plugin-single-instance.workspace = true
|
||||
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -18,25 +18,5 @@
|
||||
<string>A Minecraft mod wants to access your camera.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>A Minecraft mod wants to access your microphone.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>asset.localhost</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>textures.minecraft.net</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -99,24 +99,6 @@ fn main() {
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
),
|
||||
)
|
||||
.plugin(
|
||||
"minecraft-skins",
|
||||
InlinedPlugin::new()
|
||||
.commands(&[
|
||||
"get_available_capes",
|
||||
"get_available_skins",
|
||||
"add_and_equip_custom_skin",
|
||||
"set_default_cape",
|
||||
"equip_skin",
|
||||
"remove_custom_skin",
|
||||
"unequip_skin",
|
||||
"normalize_skin_texture",
|
||||
"get_dragged_skin_data",
|
||||
])
|
||||
.default_permission(
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
),
|
||||
)
|
||||
.plugin(
|
||||
"mr-auth",
|
||||
InlinedPlugin::new()
|
||||
@@ -169,6 +151,7 @@ fn main() {
|
||||
"profile_update_managed_modrinth_version",
|
||||
"profile_repair_managed_modrinth",
|
||||
"profile_run",
|
||||
"profile_run_credentials",
|
||||
"profile_kill",
|
||||
"profile_edit",
|
||||
"profile_edit_icon",
|
||||
|
||||
@@ -19,21 +19,12 @@
|
||||
"window-state:default",
|
||||
"window-state:allow-restore-state",
|
||||
"window-state:allow-save-window-state",
|
||||
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "https://modrinth.com/*" },
|
||||
{ "url": "https://*.modrinth.com/*" }
|
||||
]
|
||||
},
|
||||
|
||||
"auth:default",
|
||||
"import:default",
|
||||
"jre:default",
|
||||
"logs:default",
|
||||
"metadata:default",
|
||||
"minecraft-skins:default",
|
||||
"mr-auth:default",
|
||||
"profile-create:default",
|
||||
"pack:default",
|
||||
|
||||
@@ -41,8 +41,8 @@ pub async fn jre_find_filtered_jres(
|
||||
// Validates JRE at a given path
|
||||
// Returns None if the path is not a valid JRE
|
||||
#[tauri::command]
|
||||
pub async fn jre_get_jre(path: PathBuf) -> Result<JavaVersion> {
|
||||
Ok(jre::check_jre(path).await?)
|
||||
pub async fn jre_get_jre(path: PathBuf) -> Result<Option<JavaVersion>> {
|
||||
jre::check_jre(path).await.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
// Tests JRE of a certain version
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
use crate::api::Result;
|
||||
|
||||
use std::path::Path;
|
||||
use theseus::minecraft_skins::{
|
||||
self, Bytes, Cape, MinecraftSkinVariant, Skin, UrlOrBlob,
|
||||
};
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("minecraft-skins")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_available_capes,
|
||||
get_available_skins,
|
||||
add_and_equip_custom_skin,
|
||||
set_default_cape,
|
||||
equip_skin,
|
||||
remove_custom_skin,
|
||||
unequip_skin,
|
||||
normalize_skin_texture,
|
||||
get_dragged_skin_data,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|get_available_capes')`
|
||||
///
|
||||
/// See also: [minecraft_skins::get_available_capes]
|
||||
#[tauri::command]
|
||||
pub async fn get_available_capes() -> Result<Vec<Cape>> {
|
||||
Ok(minecraft_skins::get_available_capes().await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|get_available_skins')`
|
||||
///
|
||||
/// See also: [minecraft_skins::get_available_skins]
|
||||
#[tauri::command]
|
||||
pub async fn get_available_skins() -> Result<Vec<Skin>> {
|
||||
Ok(minecraft_skins::get_available_skins().await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape_override)`
|
||||
///
|
||||
/// See also: [minecraft_skins::add_and_equip_custom_skin]
|
||||
#[tauri::command]
|
||||
pub async fn add_and_equip_custom_skin(
|
||||
texture_blob: Bytes,
|
||||
variant: MinecraftSkinVariant,
|
||||
cape_override: Option<Cape>,
|
||||
) -> Result<()> {
|
||||
Ok(minecraft_skins::add_and_equip_custom_skin(
|
||||
texture_blob,
|
||||
variant,
|
||||
cape_override,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|set_default_cape', cape)`
|
||||
///
|
||||
/// See also: [minecraft_skins::set_default_cape]
|
||||
#[tauri::command]
|
||||
pub async fn set_default_cape(cape: Option<Cape>) -> Result<()> {
|
||||
Ok(minecraft_skins::set_default_cape(cape).await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|equip_skin', skin)`
|
||||
///
|
||||
/// See also: [minecraft_skins::equip_skin]
|
||||
#[tauri::command]
|
||||
pub async fn equip_skin(skin: Skin) -> Result<()> {
|
||||
Ok(minecraft_skins::equip_skin(skin).await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|remove_custom_skin', skin)`
|
||||
///
|
||||
/// See also: [minecraft_skins::remove_custom_skin]
|
||||
#[tauri::command]
|
||||
pub async fn remove_custom_skin(skin: Skin) -> Result<()> {
|
||||
Ok(minecraft_skins::remove_custom_skin(skin).await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|unequip_skin')`
|
||||
///
|
||||
/// See also: [minecraft_skins::unequip_skin]
|
||||
#[tauri::command]
|
||||
pub async fn unequip_skin() -> Result<()> {
|
||||
Ok(minecraft_skins::unequip_skin().await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|normalize_skin_texture')`
|
||||
///
|
||||
/// See also: [minecraft_skins::normalize_skin_texture]
|
||||
#[tauri::command]
|
||||
pub async fn normalize_skin_texture(texture: UrlOrBlob) -> Result<Bytes> {
|
||||
Ok(minecraft_skins::normalize_skin_texture(&texture).await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|get_dragged_skin_data', path)`
|
||||
///
|
||||
/// See also: [minecraft_skins::get_dragged_skin_data]
|
||||
#[tauri::command]
|
||||
pub async fn get_dragged_skin_data(path: String) -> Result<Bytes> {
|
||||
let path = Path::new(&path);
|
||||
Ok(minecraft_skins::get_dragged_skin_data(path).await?)
|
||||
}
|
||||
@@ -7,7 +7,6 @@ pub mod import;
|
||||
pub mod jre;
|
||||
pub mod logs;
|
||||
pub mod metadata;
|
||||
pub mod minecraft_skins;
|
||||
pub mod mr_auth;
|
||||
pub mod pack;
|
||||
pub mod process;
|
||||
|
||||
@@ -28,6 +28,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
profile_update_managed_modrinth_version,
|
||||
profile_repair_managed_modrinth,
|
||||
profile_run,
|
||||
profile_run_credentials,
|
||||
profile_kill,
|
||||
profile_edit,
|
||||
profile_edit_icon,
|
||||
@@ -255,6 +256,22 @@ pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
|
||||
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]
|
||||
pub async fn profile_kill(path: &str) -> Result<()> {
|
||||
profile::kill(path).await?;
|
||||
|
||||
@@ -183,7 +183,6 @@ fn main() {
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
@@ -249,7 +248,6 @@ fn main() {
|
||||
.plugin(api::logs::init())
|
||||
.plugin(api::jre::init())
|
||||
.plugin(api::metadata::init())
|
||||
.plugin(api::minecraft_skins::init())
|
||||
.plugin(api::pack::init())
|
||||
.plugin(api::process::init())
|
||||
.plugin(api::profile::init())
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
{
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": "v1Compatible",
|
||||
"windows": {
|
||||
"signCommand": {
|
||||
"cmd": "jsign",
|
||||
"args": [
|
||||
"sign",
|
||||
"--verbose",
|
||||
"--storetype",
|
||||
"DIGICERTONE",
|
||||
"--keystore",
|
||||
"https://clientauth.one.digicert.com",
|
||||
"--storepass",
|
||||
"env:DIGICERT_ONE_SIGNER_CREDENTIALS",
|
||||
"--tsaurl",
|
||||
"https://timestamp.sectigo.com,http://timestamp.digicert.com",
|
||||
"%1"
|
||||
]
|
||||
}
|
||||
}
|
||||
"createUpdaterArtifacts": "v1Compatible"
|
||||
},
|
||||
"build": {
|
||||
"features": ["updater"]
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"externalBin": [],
|
||||
"icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com",
|
||||
"nsis": {
|
||||
"installMode": "perMachine",
|
||||
"installerHooks": "./nsis/hooks.nsi"
|
||||
@@ -27,6 +30,7 @@
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "",
|
||||
"linux": {
|
||||
"deb": {
|
||||
@@ -41,7 +45,7 @@
|
||||
]
|
||||
},
|
||||
"productName": "Modrinth App",
|
||||
"version": "../app-frontend/package.json",
|
||||
"version": "0.9.5",
|
||||
"mainBinaryName": "Modrinth App",
|
||||
"identifier": "ModrinthApp",
|
||||
"plugins": {
|
||||
@@ -86,9 +90,9 @@
|
||||
"capabilities": ["ads", "core", "plugins"],
|
||||
"csp": {
|
||||
"default-src": "'self' customprotocol: asset:",
|
||||
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:",
|
||||
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs",
|
||||
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:",
|
||||
"style-src": "'unsafe-inline' 'self'",
|
||||
"script-src": "https://*.posthog.com 'self'",
|
||||
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.88.0 AS build
|
||||
FROM rust:1.87.0 AS build
|
||||
ENV PKG_CONFIG_ALLOW_CROSS=1
|
||||
|
||||
WORKDIR /usr/src/daedalus
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
Support: https://support.modrinth.com
|
||||
Status page: https://status.modrinth.com
|
||||
Roadmap: https://roadmap.modrinth.com
|
||||
Blog and newsletter: https://modrinth.com/news
|
||||
Blog and newsletter: https://blog.modrinth.com/subscribe?utm_medium=social&utm_source=discord&utm_campaign=welcome
|
||||
API documentation: https://docs.modrinth.com
|
||||
Modrinth source code: https://github.com/modrinth
|
||||
Help translate Modrinth: https://crowdin.com/project/modrinth
|
||||
|
||||
@@ -40,9 +40,7 @@
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@modrinth/blog": "workspace:*",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"ace-builds": "^1.36.2",
|
||||
@@ -61,6 +59,7 @@
|
||||
"qrcode.vue": "^3.4.0",
|
||||
"semver": "^7.5.4",
|
||||
"three": "^0.172.0",
|
||||
"@types/three": "^0.172.0",
|
||||
"vue-multiselect": "3.0.0-alpha.2",
|
||||
"vue-typed-virtual-list": "^1.0.10",
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
<template>
|
||||
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
|
||||
<nuxt-link
|
||||
to="/servers"
|
||||
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
|
||||
>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
class="light-image hidden rounded-[inherit]"
|
||||
/>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
class="dark-image rounded-[inherit]"
|
||||
/>
|
||||
</nuxt-link>
|
||||
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
|
||||
<p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
|
||||
<nuxt-link to="/plus" class="mt-auto items-center gap-1 text-purple hover:underline">
|
||||
<span>
|
||||
Support creators and Modrinth ad-free with
|
||||
<span class="font-bold">Modrinth+</span>
|
||||
</span>
|
||||
<ChevronRightIcon class="relative top-[3px] h-5 w-5" />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
|
||||
>
|
||||
@@ -23,6 +18,8 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ChevronRightIcon } from "@modrinth/assets";
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
// {
|
||||
@@ -140,16 +137,3 @@ iframe[id^="google_ads_iframe"] {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.light,
|
||||
.light-mode {
|
||||
.dark-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.light-image {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</h1>
|
||||
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
|
||||
<button v-tooltip="`Exit moderation`" @click="exitModeration">
|
||||
<XIcon />
|
||||
<CrossIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
@@ -306,7 +306,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled v-if="!done">
|
||||
<button aria-label="Skip" @click="goToNextProject">
|
||||
<XIcon aria-hidden="true" />
|
||||
<ExitIcon aria-hidden="true" />
|
||||
<template v-if="futureProjects.length > 0">Skip</template>
|
||||
<template v-else>Exit</template>
|
||||
</button>
|
||||
@@ -335,7 +335,7 @@
|
||||
<div class="joined-buttons">
|
||||
<ButtonStyled color="red">
|
||||
<button @click="sendMessage('rejected')">
|
||||
<XIcon aria-hidden="true" /> Reject
|
||||
<CrossIcon aria-hidden="true" /> Reject
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
@@ -373,8 +373,9 @@ import {
|
||||
UpdatedIcon,
|
||||
CheckIcon,
|
||||
DropdownIcon,
|
||||
XIcon as CrossIcon,
|
||||
EyeOffIcon,
|
||||
XIcon,
|
||||
ExitIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
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,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,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>
|
||||
@@ -2,7 +2,7 @@
|
||||
<NewModal ref="modModal" :header="`Editing ${type.toLocaleLowerCase()} version`">
|
||||
<template #title>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -185,7 +185,7 @@
|
||||
Something went wrong trying to load versions for this {{ type.toLocaleLowerCase() }}.
|
||||
Please try again later or contact support if the issue persists.
|
||||
</span>
|
||||
<CopyCode class="!mt-2 !break-all" :text="versionsError" />
|
||||
<LazyUiCopyCode class="!mt-2 !break-all" :text="versionsError" />
|
||||
</div>
|
||||
</Admonition>
|
||||
|
||||
@@ -236,7 +236,7 @@ import {
|
||||
GameIcon,
|
||||
ExternalIcon,
|
||||
} 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 { ref, computed } from "vue";
|
||||
import { formatCategory, formatVersionsForDisplay, type Mod, type Version } from "@modrinth/utils";
|
||||
|
||||
@@ -1,176 +1,124 @@
|
||||
<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]">
|
||||
<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"
|
||||
<p
|
||||
v-if="isMrpackModalSecondPhase"
|
||||
:style="{
|
||||
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
|
||||
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
|
||||
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
|
||||
}"
|
||||
>
|
||||
<div v-if="isLoading" class="w-full">
|
||||
<div class="mb-2 flex justify-between text-sm">
|
||||
<Transition name="phrase-fade" mode="out-in">
|
||||
<span :key="currentPhrase" class="text-lg font-medium text-contrast">{{
|
||||
currentPhrase
|
||||
}}</span>
|
||||
</Transition>
|
||||
<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
|
||||
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"
|
||||
>
|
||||
This will reinstall your server and erase all data. You may want to back up your server
|
||||
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`" />
|
||||
<UploadIcon class="size-10" />
|
||||
</div>
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||
<button
|
||||
v-tooltip="backupInProgress ? 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>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-10"
|
||||
>
|
||||
<path d="M5 9v6" />
|
||||
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
|
||||
</svg>
|
||||
<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>
|
||||
</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>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import {
|
||||
UploadIcon,
|
||||
RightArrowIcon,
|
||||
XIcon,
|
||||
ServerIcon,
|
||||
ArrowBigRightDashIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { formatBytes, ModrinthServersFetchError } from "@modrinth/utils";
|
||||
import { onMounted, onUnmounted } from "vue";
|
||||
import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
|
||||
import { ModrinthServersFetchError } from "@modrinth/utils";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
import type { ModrinthServer } from "~/composables/servers/modrinth-servers";
|
||||
|
||||
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);
|
||||
});
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer;
|
||||
@@ -187,49 +135,6 @@ const hardReset = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const loadingServerCheck = ref(false);
|
||||
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 canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
|
||||
@@ -248,46 +153,18 @@ const handleReinstall = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mrpackFile.value) {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "No file selected",
|
||||
text: "Choose a .mrpack file before installing.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
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", {
|
||||
loader: "mrpack",
|
||||
@@ -299,44 +176,36 @@ const handleReinstall = async () => {
|
||||
window.scrollTo(0, 0);
|
||||
hide();
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
|
||||
if (error instanceof ModrinthServersFetchError && error?.statusCode === 429) {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Cannot upload and install modpack to server",
|
||||
title: "Cannot reinstall server",
|
||||
text: "You are being rate limited. Please try again later.",
|
||||
type: "error",
|
||||
});
|
||||
} else {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Modpack upload and install failed",
|
||||
text: "An unexpected error occurred while uploading/installing. Please try again later.",
|
||||
title: "Reinstall Failed",
|
||||
text: "An unexpected error occurred while reinstalling. Please try again later.",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
if (phraseInterval) {
|
||||
clearInterval(phraseInterval);
|
||||
phraseInterval = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onShow = () => {
|
||||
hardReset.value = false;
|
||||
isMrpackModalSecondPhase.value = false;
|
||||
loadingServerCheck.value = false;
|
||||
isLoading.value = false;
|
||||
mrpackFile.value = null;
|
||||
uploadProgress.value = 0;
|
||||
uploadedBytes.value = 0;
|
||||
totalBytes.value = 0;
|
||||
currentPhrase.value = "Uploading...";
|
||||
usedPhrases.value.clear();
|
||||
if (phraseInterval) {
|
||||
clearInterval(phraseInterval);
|
||||
phraseInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onHide = () => {
|
||||
onShow();
|
||||
};
|
||||
|
||||
const show = () => mrpackModal.value?.show();
|
||||
@@ -349,14 +218,4 @@ defineExpose({ show, hide });
|
||||
.stylized-toggle:checked::after {
|
||||
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>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
v-if="projectData?.title"
|
||||
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
|
||||
>
|
||||
<Avatar
|
||||
<UiAvatar
|
||||
:src="iconUrl"
|
||||
no-shadow
|
||||
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
|
||||
update your billing information or contact Modrinth Support for more information.
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
<UiCopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
@@ -83,7 +83,6 @@
|
||||
import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets";
|
||||
import type { Project, Server } from "@modrinth/utils";
|
||||
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
|
||||
import { Avatar, CopyCode } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps<Partial<Server>>();
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
<nuxt-link
|
||||
|
||||
<component
|
||||
:is="loading ? 'div' : 'NuxtLink'"
|
||||
: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="loading ? '' : 'transition-transform duration-100 hover:scale-105 active:scale-100'"
|
||||
@@ -62,17 +64,16 @@
|
||||
</div>
|
||||
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
|
||||
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
||||
</nuxt-link>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 type { Stats } from "@modrinth/utils";
|
||||
|
||||
const flags = useFeatureFlags();
|
||||
const route = useNativeRoute();
|
||||
const serverId = route.params.id;
|
||||
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
|
||||
@@ -126,7 +127,7 @@ const metrics = computed(() => {
|
||||
title: "CPU usage",
|
||||
value: "0.00%",
|
||||
max: "100%",
|
||||
icon: CpuIcon,
|
||||
icon: CPUIcon,
|
||||
data: cpuData.value,
|
||||
showGraph: false,
|
||||
warning: null,
|
||||
@@ -157,21 +158,17 @@ const metrics = computed(() => {
|
||||
title: "CPU usage",
|
||||
value: `${cpuPercent.toFixed(2)}%`,
|
||||
max: "100%",
|
||||
icon: CpuIcon,
|
||||
icon: CPUIcon,
|
||||
data: cpuData.value,
|
||||
showGraph: true,
|
||||
warning: cpuPercent >= 90 ? "CPU usage is very high" : null,
|
||||
},
|
||||
{
|
||||
title: "Memory usage",
|
||||
value:
|
||||
userPreferences.value.ramAsNumber || flags.developerMode
|
||||
? formatBytes(stats.value.ram_usage_bytes)
|
||||
: `${ramPercent.toFixed(2)}%`,
|
||||
max:
|
||||
userPreferences.value.ramAsNumber || flags.developerMode
|
||||
? formatBytes(stats.value.ram_total_bytes)
|
||||
: "100%",
|
||||
value: userPreferences.value.ramAsNumber
|
||||
? formatBytes(stats.value.ram_usage_bytes)
|
||||
: `${ramPercent.toFixed(2)}%`,
|
||||
max: userPreferences.value.ramAsNumber ? formatBytes(stats.value.ram_total_bytes) : "100%",
|
||||
icon: DatabaseIcon,
|
||||
data: ramData.value,
|
||||
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 context = {};
|
||||
const formatters = new WeakMap<IntlController<any>, Formatter>();
|
||||
|
||||
let formatter = formatters.get(context);
|
||||
export function useCompactNumber(): Formatter {
|
||||
const vintl = useVIntl();
|
||||
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(locale, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
formatters.set(context, formatter);
|
||||
let formatter = formatters.get(vintl);
|
||||
|
||||
if (formatter == null) {
|
||||
const formatterRef = computed(() => createFormatter(vintl.intl));
|
||||
formatter = (value, options) => formatterRef.value(value, options);
|
||||
formatters.set(vintl, formatter);
|
||||
}
|
||||
|
||||
function format(value: number): string {
|
||||
let formattedValue = value;
|
||||
if (truncate) {
|
||||
const scale = Math.pow(10, fractionDigits);
|
||||
formattedValue = Math.floor(value * scale) / scale;
|
||||
}
|
||||
return formatter!.format(formattedValue);
|
||||
}
|
||||
|
||||
return format;
|
||||
return formatter;
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
projectBackground: false,
|
||||
searchBackground: false,
|
||||
advancedDebugInfo: false,
|
||||
showProjectPageDownloadModalServersPromo: false,
|
||||
showProjectPageCreateServersTooltip: true,
|
||||
showProjectPageQuickServerButton: false,
|
||||
// advancedRendering: true,
|
||||
// externalLinksNewTab: true,
|
||||
// notUsingBlockers: false,
|
||||
|
||||
@@ -98,67 +98,28 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
||||
}
|
||||
}
|
||||
|
||||
reinstallFromMrpack(
|
||||
mrpack: File,
|
||||
hardReset: boolean = false,
|
||||
): {
|
||||
promise: Promise<void>;
|
||||
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => void;
|
||||
} {
|
||||
async reinstallFromMrpack(mrpack: File, hardReset: boolean = false): Promise<void> {
|
||||
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 () => {
|
||||
try {
|
||||
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/reinstallFromMrpack`);
|
||||
const response = await fetch(
|
||||
`https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(30 * 60 * 1000),
|
||||
},
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
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),
|
||||
};
|
||||
if (!response.ok) {
|
||||
throw new Error(`[pyroservers] native fetch err status: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async suspend(status: boolean): Promise<void> {
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
"
|
||||
>
|
||||
<nuxt-link to="/resourcepacks">
|
||||
<PaintbrushIcon aria-hidden="true" /> Resource Packs
|
||||
<PaintBrushIcon aria-hidden="true" /> Resource Packs
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
@@ -221,7 +221,7 @@
|
||||
v-if="route.name === 'search-mods' || route.path.startsWith('/mod/')"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<PaintbrushIcon
|
||||
<PaintBrushIcon
|
||||
v-else-if="
|
||||
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
|
||||
"
|
||||
@@ -250,7 +250,7 @@
|
||||
|
||||
<template #mods> <BoxIcon aria-hidden="true" /> Mods </template>
|
||||
<template #resourcepacks>
|
||||
<PaintbrushIcon aria-hidden="true" /> Resource Packs
|
||||
<PaintBrushIcon aria-hidden="true" /> Resource Packs
|
||||
</template>
|
||||
<template #datapacks> <BracesIcon aria-hidden="true" /> Data Packs </template>
|
||||
<template #plugins> <PlugIcon aria-hidden="true" /> Plugins </template>
|
||||
@@ -696,14 +696,14 @@ import {
|
||||
CurrencyIcon,
|
||||
BracesIcon,
|
||||
GlassesIcon,
|
||||
PaintbrushIcon,
|
||||
PaintBrushIcon,
|
||||
PackageOpenIcon,
|
||||
DiscordIcon,
|
||||
BlueskyIcon,
|
||||
TumblrIcon,
|
||||
TwitterIcon,
|
||||
MastodonIcon,
|
||||
GithubIcon,
|
||||
GitHubIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import {
|
||||
@@ -1202,7 +1202,7 @@ const socialLinks = [
|
||||
defineMessage({ id: "layout.footer.social.github", defaultMessage: "GitHub" }),
|
||||
),
|
||||
href: "https://github.com/modrinth",
|
||||
icon: GithubIcon,
|
||||
icon: GitHubIcon,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1211,9 +1211,9 @@ const footerLinks = [
|
||||
label: formatMessage(defineMessage({ id: "layout.footer.about", defaultMessage: "About" })),
|
||||
links: [
|
||||
{
|
||||
href: "/news",
|
||||
href: "https://blog.modrinth.com",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.about.news", defaultMessage: "News" }),
|
||||
defineMessage({ id: "layout.footer.about.blog", defaultMessage: "Blog" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -383,8 +383,8 @@
|
||||
"layout.footer.about": {
|
||||
"message": "About"
|
||||
},
|
||||
"layout.footer.about.news": {
|
||||
"message": "News"
|
||||
"layout.footer.about.blog": {
|
||||
"message": "Blog"
|
||||
},
|
||||
"layout.footer.about.careers": {
|
||||
"message": "Careers"
|
||||
|
||||
@@ -452,16 +452,6 @@
|
||||
{{ formatCategory(currentPlatform) }}.
|
||||
</p>
|
||||
</AutomaticAccordion>
|
||||
<ServersPromo
|
||||
v-if="flags.showProjectPageDownloadModalServersPromo"
|
||||
:link="`/servers#plan`"
|
||||
@close="
|
||||
() => {
|
||||
flags.showProjectPageDownloadModalServersPromo = false;
|
||||
saveFeatureFlags();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
@@ -505,64 +495,6 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-if="canCreateServerFrom && flags.showProjectPageQuickServerButton"
|
||||
theme="dismissable-prompt"
|
||||
:triggers="[]"
|
||||
:shown="flags.showProjectPageCreateServersTooltip"
|
||||
:auto-hide="false"
|
||||
placement="bottom-start"
|
||||
>
|
||||
<ButtonStyled size="large" circular>
|
||||
<nuxt-link
|
||||
v-tooltip="'Create a server'"
|
||||
:to="`/servers?project=${project.id}#plan`"
|
||||
@click="
|
||||
() => {
|
||||
flags.showProjectPageCreateServersTooltip = false;
|
||||
saveFeatureFlags();
|
||||
}
|
||||
"
|
||||
>
|
||||
<ServerPlusIcon aria-hidden="true" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<template #popper>
|
||||
<div class="experimental-styles-within flex max-w-60 flex-col gap-1">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 class="m-0 flex items-center gap-2 text-base font-bold text-contrast">
|
||||
Create a server
|
||||
<TagItem
|
||||
:style="{
|
||||
'--_color': 'var(--color-brand)',
|
||||
'--_bg-color': 'var(--color-brand-highlight)',
|
||||
}"
|
||||
>New</TagItem
|
||||
>
|
||||
</h3>
|
||||
<ButtonStyled size="small" circular>
|
||||
<button
|
||||
v-tooltip="`Don't show again`"
|
||||
@click="
|
||||
() => {
|
||||
flags.showProjectPageCreateServersTooltip = false;
|
||||
saveFeatureFlags();
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<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!
|
||||
</p>
|
||||
<p class="m-0 text-wrap text-sm font-bold text-primary">
|
||||
Starting at $5<span class="text-xs"> / month</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
<ClientOnly>
|
||||
<ButtonStyled
|
||||
size="large"
|
||||
@@ -762,7 +694,12 @@
|
||||
:tags="tags"
|
||||
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
|
||||
:project="project"
|
||||
:link-target="$external()"
|
||||
@@ -913,14 +850,12 @@ import {
|
||||
ReportIcon,
|
||||
ScaleIcon,
|
||||
SearchIcon,
|
||||
ServerPlusIcon,
|
||||
SettingsIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
VersionIcon,
|
||||
WrenchIcon,
|
||||
ModrinthIcon,
|
||||
XIcon,
|
||||
} from "@modrinth/assets";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -937,8 +872,6 @@ import {
|
||||
ProjectSidebarLinks,
|
||||
ProjectStatusBadge,
|
||||
ScrollablePanel,
|
||||
TagItem,
|
||||
ServersPromo,
|
||||
useRelativeTime,
|
||||
} from "@modrinth/ui";
|
||||
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
||||
@@ -952,7 +885,6 @@ import {
|
||||
} from "@modrinth/utils";
|
||||
import { navigateTo } from "#app";
|
||||
import dayjs from "dayjs";
|
||||
import { Tooltip } from "floating-vue";
|
||||
import Accordion from "~/components/ui/Accordion.vue";
|
||||
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
||||
@@ -966,7 +898,6 @@ import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
|
||||
import { userCollectProject } from "~/composables/user.js";
|
||||
import { reportProject } from "~/utils/report-helpers.ts";
|
||||
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
||||
|
||||
const data = useNuxtApp();
|
||||
const route = useNativeRoute();
|
||||
@@ -1380,10 +1311,6 @@ const description = computed(
|
||||
} by ${members.value.find((x) => x.is_owner)?.user?.username || "a Creator"} on Modrinth`,
|
||||
);
|
||||
|
||||
const canCreateServerFrom = computed(() => {
|
||||
return project.value.project_type === "modpack" && project.value.server_side !== "unsupported";
|
||||
});
|
||||
|
||||
if (!route.name.startsWith("type-id-settings")) {
|
||||
useSeoMeta({
|
||||
title: () => title.value,
|
||||
@@ -1752,33 +1679,4 @@ const navLinks = computed(() => {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.servers-popup {
|
||||
box-shadow:
|
||||
0 0 12px 1px rgba(0, 175, 92, 0.6),
|
||||
var(--shadow-floating);
|
||||
|
||||
&::before {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-bottom: 6px solid var(--color-button-bg);
|
||||
content: " ";
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: 17px;
|
||||
}
|
||||
&::after {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-bottom: 5px solid var(--color-raised-bg);
|
||||
content: " ";
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
Listed in search results
|
||||
</li>
|
||||
<li v-else>
|
||||
<XIcon class="bad" />
|
||||
<ExitIcon class="bad" />
|
||||
Not listed in search results
|
||||
</li>
|
||||
<li v-if="isListed(project)">
|
||||
@@ -58,11 +58,11 @@
|
||||
Listed on the profiles of members
|
||||
</li>
|
||||
<li v-else>
|
||||
<XIcon class="bad" />
|
||||
<ExitIcon class="bad" />
|
||||
Not listed on the profiles of members
|
||||
</li>
|
||||
<li v-if="isPrivate(project)">
|
||||
<XIcon class="bad" />
|
||||
<ExitIcon class="bad" />
|
||||
Not accessible with a direct link
|
||||
</li>
|
||||
<li v-else>
|
||||
@@ -92,7 +92,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { XIcon, CheckIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import { ExitIcon, CheckIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import { Badge } from "@modrinth/ui";
|
||||
import ConversationThread from "~/components/ui/thread/ConversationThread.vue";
|
||||
import {
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
{{ 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;
|
||||
otherwise, the license text will be displayed. See our
|
||||
<nuxt-link
|
||||
to="/news/article/licensing-guide/"
|
||||
<a
|
||||
href="https://blog.modrinth.com/licensing-guide/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-link"
|
||||
>
|
||||
licensing guide
|
||||
</nuxt-link>
|
||||
</a>
|
||||
for more information.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -153,7 +153,10 @@
|
||||
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
|
||||
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </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.type }}
|
||||
@@ -216,6 +219,7 @@ import dayjs from "dayjs";
|
||||
import { products } from "~/generated/state.json";
|
||||
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
|
||||
|
||||
const flags = useFeatureFlags();
|
||||
const route = useRoute();
|
||||
const data = useNuxtApp();
|
||||
const vintl = useVIntl();
|
||||
@@ -285,13 +289,13 @@ const selectedCharge = ref(null);
|
||||
const refundType = ref("full");
|
||||
const refundTypes = ref(["full", "partial", "none"]);
|
||||
const refundAmount = ref(0);
|
||||
const unprovision = ref(true);
|
||||
const unprovision = ref(false);
|
||||
|
||||
function showRefundModal(charge) {
|
||||
selectedCharge.value = charge;
|
||||
refundType.value = "full";
|
||||
refundAmount.value = 0;
|
||||
unprovision.value = true;
|
||||
unprovision.value = false;
|
||||
refundModal.value.show();
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -248,7 +248,9 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<AdPlaceholder v-if="!auth.user" />
|
||||
<AdPlaceholder
|
||||
v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus"
|
||||
/>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<nav class="navigation-card">
|
||||
@@ -490,6 +492,7 @@ const route = useNativeRoute();
|
||||
const auth = await useAuth();
|
||||
const cosmetics = useCosmetics();
|
||||
const tags = useTags();
|
||||
const flags = useFeatureFlags();
|
||||
|
||||
const isEditing = ref(false);
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
/>
|
||||
<div class="push-right input-group">
|
||||
<button class="iconified-button" @click="$refs.editLinksModal.hide()">
|
||||
<XIcon />
|
||||
<CrossIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button class="iconified-button brand-button" @click="bulkEditLinks()">
|
||||
@@ -199,8 +199,8 @@
|
||||
class="square-button"
|
||||
@click="updateDescending()"
|
||||
>
|
||||
<SortDescIcon v-if="descending" />
|
||||
<SortAscIcon v-else />
|
||||
<DescendingIcon v-if="descending" />
|
||||
<AscendingIcon v-else />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,12 +306,12 @@ import {
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
XIcon,
|
||||
XIcon as CrossIcon,
|
||||
IssuesIcon,
|
||||
EditIcon,
|
||||
SaveIcon,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
SortAscendingIcon as AscendingIcon,
|
||||
SortDescendingIcon as DescendingIcon,
|
||||
} from "@modrinth/assets";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -337,15 +337,15 @@ export default defineNuxtComponent({
|
||||
Checkbox,
|
||||
IssuesIcon,
|
||||
PlusIcon,
|
||||
XIcon,
|
||||
CrossIcon,
|
||||
EditIcon,
|
||||
SaveIcon,
|
||||
Modal,
|
||||
ModalCreation,
|
||||
Multiselect,
|
||||
CopyCode,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
AscendingIcon,
|
||||
DescendingIcon,
|
||||
},
|
||||
async setup() {
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -147,9 +147,9 @@
|
||||
<tbody>
|
||||
<tr v-for="item in platformRevenueData" :key="item.time">
|
||||
<td>{{ formatDate(dayjs.unix(item.time)) }}</td>
|
||||
<td>{{ formatMoney(Number(item.revenue) + Number(item.creator_revenue)) }}</td>
|
||||
<td>{{ formatMoney(Number(item.creator_revenue)) }}</td>
|
||||
<td>{{ formatMoney(Number(item.revenue)) }}</td>
|
||||
<td>{{ formatMoney(item.revenue) }}</td>
|
||||
<td>{{ formatMoney(item.creator_revenue) }}</td>
|
||||
<td>{{ formatMoney(item.revenue - item.creator_revenue) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -187,6 +187,6 @@ const { data: transparencyInformation } = await useAsyncData("payout/platform_re
|
||||
}),
|
||||
);
|
||||
|
||||
const platformRevenue = (transparencyInformation.value as any)?.all_time;
|
||||
const platformRevenueData = (transparencyInformation.value as any)?.data?.slice(0, 5) ?? [];
|
||||
const platformRevenue = transparencyInformation.value.all_time;
|
||||
const platformRevenueData = transparencyInformation.value.data.slice(0, 5);
|
||||
</script>
|
||||
|
||||
@@ -122,8 +122,8 @@
|
||||
<h3>Creator Monetization Program data</h3>
|
||||
<p>
|
||||
When you sign up for our
|
||||
<nuxt-link to="/news/article/creator-monetization-beta">
|
||||
Creator Monetization Program</nuxt-link
|
||||
<a href="https://blog.modrinth.com/p/creator-monetization-beta">
|
||||
Creator Monetization Program</a
|
||||
>
|
||||
(the "CMP"), we collect:
|
||||
</p>
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x) + 's')"
|
||||
/>
|
||||
<button v-if="oldestFirst" class="iconified-button push-right" @click="oldestFirst = false">
|
||||
<SortDescIcon />
|
||||
<SortDescendingIcon />
|
||||
Sorting by oldest
|
||||
</button>
|
||||
<button v-else class="iconified-button push-right" @click="oldestFirst = true">
|
||||
<SortAscIcon />
|
||||
<SortAscendingIcon />
|
||||
Sorting by newest
|
||||
</button>
|
||||
<button
|
||||
@@ -109,8 +109,8 @@ import { Avatar, ProjectStatusBadge, Chips, useRelativeTime } from "@modrinth/ui
|
||||
import {
|
||||
UnknownIcon,
|
||||
EyeIcon,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
SortAscendingIcon,
|
||||
SortDescendingIcon,
|
||||
IssuesIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { RssIcon, GitGraphIcon } from "@modrinth/assets";
|
||||
import dayjs from "dayjs";
|
||||
import { articles as rawArticles } from "@modrinth/blog";
|
||||
import { computed } from "vue";
|
||||
import ShareArticleButtons from "~/components/ui/ShareArticleButtons.vue";
|
||||
import NewsletterButton from "~/components/ui/NewsletterButton.vue";
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const route = useRoute();
|
||||
|
||||
const rawArticle = rawArticles.find((article) => article.slug === route.params.slug);
|
||||
|
||||
if (!rawArticle) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: "The requested article could not be found.",
|
||||
});
|
||||
}
|
||||
|
||||
const html = await rawArticle.html();
|
||||
|
||||
const article = computed(() => ({
|
||||
...rawArticle,
|
||||
path: `/news/${rawArticle.slug}`,
|
||||
thumbnail: rawArticle.thumbnail
|
||||
? `/news/article/${rawArticle.slug}/thumbnail.webp`
|
||||
: `/news/default.webp`,
|
||||
title: rawArticle.title,
|
||||
summary: rawArticle.summary,
|
||||
date: rawArticle.date,
|
||||
html,
|
||||
}));
|
||||
|
||||
const articleTitle = computed(() => article.value.title);
|
||||
const articleUrl = computed(() => `https://modrinth.com/news/article/${route.params.slug}`);
|
||||
|
||||
const thumbnailPath = computed(() =>
|
||||
article.value.thumbnail
|
||||
? `${config.public.siteUrl}${article.value.thumbnail}`
|
||||
: `${config.public.siteUrl}/news/default.jpg`,
|
||||
);
|
||||
|
||||
const dayjsDate = computed(() => dayjs(article.value.date));
|
||||
|
||||
useSeoMeta({
|
||||
title: () => `${articleTitle.value} - Modrinth News`,
|
||||
ogTitle: () => articleTitle.value,
|
||||
description: () => article.value.summary,
|
||||
ogDescription: () => article.value.summary,
|
||||
ogType: "article",
|
||||
ogImage: () => thumbnailPath.value,
|
||||
articlePublishedTime: () => dayjsDate.value.toISOString(),
|
||||
twitterCard: "summary_large_image",
|
||||
twitterImage: () => thumbnailPath.value,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page experimental-styles-within py-6">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-4 border-0 border-b-[1px] border-solid border-divider px-6 pb-6"
|
||||
>
|
||||
<nuxt-link :to="`/news`">
|
||||
<h1 class="m-0 text-3xl font-extrabold hover:underline">News</h1>
|
||||
</nuxt-link>
|
||||
<div class="flex gap-2">
|
||||
<NewsletterButton />
|
||||
<ButtonStyled circular>
|
||||
<a v-tooltip="`RSS feed`" aria-label="RSS feed" href="/news/feed/rss.xml" target="_blank">
|
||||
<RssIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular icon-only>
|
||||
<a v-tooltip="`Changelog`" href="/news/changelog" aria-label="Changelog">
|
||||
<GitGraphIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<article class="mt-6 flex flex-col gap-4 px-6">
|
||||
<h2 class="m-0 text-2xl font-extrabold leading-tight sm:text-4xl">{{ article.title }}</h2>
|
||||
<p class="m-0 text-base leading-tight sm:text-lg">{{ article.summary }}</p>
|
||||
<div class="mt-auto text-sm text-secondary sm:text-base">
|
||||
Posted on {{ dayjsDate.format("MMMM D, YYYY") }}
|
||||
</div>
|
||||
<ShareArticleButtons :title="article.title" :url="articleUrl" />
|
||||
<img
|
||||
:src="article.thumbnail"
|
||||
class="aspect-video w-full rounded-xl border-[1px] border-solid border-button-border object-cover sm:rounded-2xl"
|
||||
:alt="article.title"
|
||||
/>
|
||||
<div class="markdown-body" v-html="article.html" />
|
||||
<h3
|
||||
class="mb-0 mt-4 border-0 border-t-[1px] border-solid border-divider pt-4 text-base font-extrabold sm:text-lg"
|
||||
>
|
||||
Share this article
|
||||
</h3>
|
||||
<ShareArticleButtons :title="article.title" :url="articleUrl" />
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
> *:not(.full-width-bg),
|
||||
> .full-width-bg > * {
|
||||
max-width: 56rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-gradient-bg {
|
||||
background: var(--brand-gradient-bg);
|
||||
border-color: var(--brand-gradient-border);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
article {
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.markdown-body) {
|
||||
h1,
|
||||
h2 {
|
||||
border-bottom: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ul > li:not(:last-child) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
strong {
|
||||
color: var(--color-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
@media (min-width: 640px) {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
@media (min-width: 640px) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.125rem;
|
||||
@media (min-width: 640px) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
@media (min-width: 640px) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
a {
|
||||
font-weight: 800;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
a {
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
border: 1px solid var(--color-button-border);
|
||||
border-radius: var(--radius-md);
|
||||
@media (min-width: 640px) {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
> img,
|
||||
> :has(img:first-child:last-child) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,21 +6,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
useSeoMeta({
|
||||
title: "Modrinth Changelog",
|
||||
ogTitle: "Modrinth Changelog",
|
||||
description: "Keep up-to-date on what's new with Modrinth.",
|
||||
ogDescription: "Keep up-to-date on what's new with Modrinth.",
|
||||
ogType: "website",
|
||||
ogImage: () => `${config.public.siteUrl}/news/changelog.webp`,
|
||||
twitterCard: "summary_large_image",
|
||||
twitterImage: () => `${config.public.siteUrl}/news/changelog.webp`,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
padding: 1rem;
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewsArticleCard } from "@modrinth/ui";
|
||||
import { ChevronRightIcon, RssIcon, GitGraphIcon } from "@modrinth/assets";
|
||||
import dayjs from "dayjs";
|
||||
import { articles as rawArticles } from "@modrinth/blog";
|
||||
import { computed, ref } from "vue";
|
||||
import NewsletterButton from "~/components/ui/NewsletterButton.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 featuredArticle = computed(() => articles.value?.[0]);
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
useSeoMeta({
|
||||
title: "Modrinth News",
|
||||
ogTitle: "Modrinth News",
|
||||
description: "Keep up-to-date on the latest news from Modrinth.",
|
||||
ogDescription: "Keep up-to-date on the latest news from Modrinth.",
|
||||
ogType: "website",
|
||||
ogImage: () => `${config.public.siteUrl}/news/thumbnail.webp`,
|
||||
twitterCard: "summary_large_image",
|
||||
twitterImage: () => `${config.public.siteUrl}/news/thumbnail.webp`,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page experimental-styles-within py-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 px-6">
|
||||
<div>
|
||||
<h1 class="m-0 text-3xl font-extrabold">News</h1>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<NewsletterButton />
|
||||
<ButtonStyled circular>
|
||||
<a v-tooltip="`RSS feed`" aria-label="RSS feed" href="/news/feed/rss.xml" target="_blank">
|
||||
<RssIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular icon-only>
|
||||
<a v-tooltip="`Changelog`" href="/news/changelog" aria-label="Changelog">
|
||||
<GitGraphIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="articles && articles.length">
|
||||
<div
|
||||
v-if="featuredArticle"
|
||||
class="full-width-bg brand-gradient-bg mt-6 border-0 border-y-[1px] border-solid py-4"
|
||||
>
|
||||
<nuxt-link
|
||||
:to="`${featuredArticle.path}`"
|
||||
class="active:scale-[0.99]! group flex cursor-pointer transition-all ease-in-out hover:brightness-125"
|
||||
>
|
||||
<article class="featured-article px-6">
|
||||
<div class="featured-image-container">
|
||||
<img
|
||||
:src="featuredArticle.thumbnail"
|
||||
class="aspect-video w-full rounded-2xl border-[1px] border-solid border-button-border object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="featured-content">
|
||||
<p class="m-0 font-bold">Featured article</p>
|
||||
<h3 class="m-0 text-3xl leading-tight group-hover:underline">
|
||||
{{ featuredArticle?.title }}
|
||||
</h3>
|
||||
<p class="m-0 text-lg leading-tight">{{ featuredArticle?.summary }}</p>
|
||||
<div class="mt-auto text-secondary">
|
||||
{{ dayjs(featuredArticle?.date).format("MMMM D, YYYY") }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 px-6">
|
||||
<div class="group flex w-fit items-center gap-1">
|
||||
<h2 class="m-0 text-xl font-extrabold">More articles</h2>
|
||||
<ChevronRightIcon
|
||||
v-if="false"
|
||||
class="ml-0 h-6 w-6 transition-all group-hover:ml-1 group-hover:text-brand"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-[repeat(auto-fill,minmax(250px,1fr))] gap-4">
|
||||
<NewsArticleCard
|
||||
v-for="article in articles.slice(1)"
|
||||
:key="article.path"
|
||||
:article="article"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="pt-4">Error: Articles could not be loaded.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
> *:not(.full-width-bg),
|
||||
> .full-width-bg > * {
|
||||
max-width: 56rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-gradient-bg {
|
||||
background: var(--brand-gradient-bg);
|
||||
border-color: var(--brand-gradient-border);
|
||||
}
|
||||
|
||||
.featured-article {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.featured-image-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.featured-content {
|
||||
flex: 1;
|
||||
min-width: 16rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.featured-article {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.featured-image-container {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.featured-content {
|
||||
order: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -98,10 +98,7 @@
|
||||
{{ formatCompactNumber(projects?.length || 0) }}
|
||||
projects
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="sumDownloads.toLocaleString()"
|
||||
class="flex items-center gap-2 font-semibold"
|
||||
>
|
||||
<div class="flex items-center gap-2 font-semibold">
|
||||
<DownloadIcon class="h-6 w-6 text-secondary" />
|
||||
{{ formatCompactNumber(sumDownloads) }}
|
||||
downloads
|
||||
@@ -149,7 +146,9 @@
|
||||
</ContentPageHeader>
|
||||
</div>
|
||||
<div class="normal-page__sidebar">
|
||||
<AdPlaceholder v-if="!auth.user" />
|
||||
<AdPlaceholder
|
||||
v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus"
|
||||
/>
|
||||
|
||||
<div class="card flex-card">
|
||||
<h2>Members</h2>
|
||||
@@ -285,13 +284,14 @@ import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
const vintl = useVIntl();
|
||||
const { formatMessage } = vintl;
|
||||
|
||||
const formatCompactNumber = useCompactNumber(true);
|
||||
const formatCompactNumber = useCompactNumber();
|
||||
|
||||
const auth = await useAuth();
|
||||
const user = await useUser();
|
||||
const cosmetics = useCosmetics();
|
||||
const route = useNativeRoute();
|
||||
const tags = useTags();
|
||||
const flags = useFeatureFlags();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
let orgId = useRouteId();
|
||||
|
||||
@@ -201,8 +201,8 @@
|
||||
icon-only
|
||||
@click="updateDescending()"
|
||||
>
|
||||
<SortDescIcon v-if="descending" />
|
||||
<SortAscIcon v-else />
|
||||
<SortDescendingIcon v-if="descending" />
|
||||
<SortAscendingIcon v-else />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,8 +308,8 @@ import {
|
||||
XIcon,
|
||||
EditIcon,
|
||||
SaveIcon,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
SortAscendingIcon,
|
||||
SortDescendingIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Button, Modal, Avatar, CopyCode, Badge, Checkbox, commonMessages } from "@modrinth/ui";
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<SparklesIcon class="h-8 w-8 text-purple" />
|
||||
<span class="text-lg font-bold">Remove all ads</span>
|
||||
<span class="leading-5 text-secondary">
|
||||
Never see an advertisement again on the Modrinth app.
|
||||
Never see an advertisement again on the Modrinth app or the website.
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 rounded-xl bg-bg-raised p-4">
|
||||
@@ -82,7 +82,7 @@
|
||||
<span class="leading-5 text-secondary">Get an exclusive badge on your user page.</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="mt-4 text-secondary">...and much more coming soon™!</span>
|
||||
<span class="mt-4 text-secondary">...and much more coming soon!</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
|
||||
@@ -55,7 +55,12 @@
|
||||
}"
|
||||
aria-label="Filters"
|
||||
>
|
||||
<AdPlaceholder v-if="!auth.user && !server" />
|
||||
<AdPlaceholder
|
||||
v-if="
|
||||
(!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus) &&
|
||||
!server
|
||||
"
|
||||
/>
|
||||
<div v-if="filtersMenuOpen" class="fixed inset-0 z-40 bg-bg"></div>
|
||||
<div
|
||||
class="flex flex-col gap-3"
|
||||
|
||||
@@ -500,7 +500,6 @@
|
||||
|
||||
<section
|
||||
id="plan"
|
||||
pyro-hash="plan"
|
||||
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
|
||||
>
|
||||
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
|
||||
@@ -604,9 +603,7 @@
|
||||
<RightArrowIcon class="shrink-0" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<p v-if="lowestPrice" class="m-0 text-sm">
|
||||
Starting at {{ formatPrice(locale, lowestPrice, selectedCurrency, true) }} / month
|
||||
</p>
|
||||
<p class="m-0 text-sm">Starting at $3/GB RAM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -625,34 +622,20 @@ import {
|
||||
VersionIcon,
|
||||
ServerIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { computed } from "vue";
|
||||
import { monthsInInterval } from "@modrinth/ui/src/utils/billing.ts";
|
||||
import { formatPrice } from "@modrinth/utils";
|
||||
import { useVIntl } from "@vintl/vintl";
|
||||
import { products } from "~/generated/state.json";
|
||||
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
|
||||
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
|
||||
import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue";
|
||||
import OptionGroup from "~/components/ui/OptionGroup.vue";
|
||||
|
||||
const { locale } = useVIntl();
|
||||
|
||||
const billingPeriods = ref(["monthly", "quarterly"]);
|
||||
const billingPeriod = ref(billingPeriods.value.includes("quarterly") ? "quarterly" : "monthly");
|
||||
|
||||
const pyroProducts = products
|
||||
.filter((p) => p.metadata.type === "pyro")
|
||||
.sort((a, b) => a.metadata.ram - b.metadata.ram);
|
||||
const pyroProducts = products.filter((p) => p.metadata.type === "pyro");
|
||||
const pyroPlanProducts = pyroProducts.filter(
|
||||
(p) => p.metadata.ram === 4096 || p.metadata.ram === 6144 || p.metadata.ram === 8192,
|
||||
);
|
||||
|
||||
const lowestPrice = computed(() => {
|
||||
const amount = pyroProducts[0]?.prices?.find(
|
||||
(price) => price.currency_code === selectedCurrency.value,
|
||||
)?.prices?.intervals?.[billingPeriod.value];
|
||||
return amount ? amount / monthsInInterval[billingPeriod.value] : undefined;
|
||||
});
|
||||
pyroPlanProducts.sort((a, b) => a.metadata.ram - b.metadata.ram);
|
||||
|
||||
const title = "Modrinth Servers";
|
||||
const description =
|
||||
@@ -816,8 +799,6 @@ async function fetchPaymentData() {
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProjectId = ref();
|
||||
|
||||
const route = useRoute();
|
||||
const isAtCapacity = computed(
|
||||
() => isSmallAtCapacity.value && isMediumAtCapacity.value && isLargeAtCapacity.value,
|
||||
@@ -836,12 +817,7 @@ const scrollToFaq = () => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
scrollToFaq();
|
||||
if (route.query?.project) {
|
||||
selectedProjectId.value = route.query?.project;
|
||||
}
|
||||
});
|
||||
onMounted(scrollToFaq);
|
||||
|
||||
watch(() => route.hash, scrollToFaq);
|
||||
|
||||
@@ -900,9 +876,9 @@ const selectProduct = async (product) => {
|
||||
await nextTick();
|
||||
|
||||
if (product === "custom") {
|
||||
purchaseModal.value?.show(billingPeriod.value, undefined, selectedProjectId.value);
|
||||
purchaseModal.value?.show(billingPeriod.value);
|
||||
} else {
|
||||
purchaseModal.value?.show(billingPeriod.value, selectedProduct.value, selectedProjectId.value);
|
||||
purchaseModal.value?.show(billingPeriod.value, selectedProduct.value);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
class="flex min-w-0 flex-1 items-center gap-2 rounded-xl p-2"
|
||||
draggable="false"
|
||||
>
|
||||
<Avatar
|
||||
<UiAvatar
|
||||
:src="mod.icon_url"
|
||||
size="sm"
|
||||
alt="Server Icon"
|
||||
@@ -349,7 +349,7 @@ import {
|
||||
FileIcon,
|
||||
IssuesIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Avatar, ButtonStyled } from "@modrinth/ui";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import type { Mod } from "@modrinth/utils";
|
||||
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<tr v-for="property in properties" :key="property.name">
|
||||
<td v-if="property.value !== 'Unknown'" class="py-3">{{ property.name }}</td>
|
||||
<td v-if="property.value !== 'Unknown'" class="px-4">
|
||||
<CopyCode :text="property.value" />
|
||||
<UiCopyCode :text="property.value" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -115,10 +115,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, CopyCode } from "@modrinth/ui";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { CopyIcon, ExternalIcon, EyeIcon, EyeOffIcon } from "@modrinth/assets";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const route = useNativeRoute();
|
||||
const serverId = route.params.id as string;
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer;
|
||||
}>();
|
||||
@@ -144,8 +147,8 @@ const copyToClipboard = (name: string, textToCopy?: string) => {
|
||||
};
|
||||
|
||||
const properties = [
|
||||
{ name: "Server ID", value: props.server.serverId ?? "Unknown" },
|
||||
{ name: "Node", value: data.value?.node?.instance ?? "Unknown" },
|
||||
{ name: "Server ID", value: serverId ?? "Unknown" },
|
||||
{ name: "Node", value: data.value?.datacenter ?? "Unknown" },
|
||||
{ name: "Kind", value: data.value?.upstream?.kind ?? data.value?.loader ?? "Unknown" },
|
||||
{ name: "Project ID", value: data.value?.upstream?.project_id ?? "Unknown" },
|
||||
{ name: "Version ID", value: data.value?.upstream?.version_id ?? "Unknown" },
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
Primary allocation
|
||||
</span>
|
||||
|
||||
<CopyCode :text="`${serverIP}:${serverPrimaryPort}`" />
|
||||
<UiCopyCode :text="`${serverIP}:${serverPrimaryPort}`" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -228,7 +228,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-row items-center gap-2 sm:w-auto">
|
||||
<CopyCode :text="`${serverIP}:${allocation.port}`" />
|
||||
<UiCopyCode :text="`${serverIP}:${allocation.port}`" />
|
||||
<ButtonStyled icon-only>
|
||||
<button
|
||||
class="!w-full sm:!w-auto"
|
||||
@@ -273,7 +273,7 @@ import {
|
||||
UploadIcon,
|
||||
IssuesIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal, ConfirmModal, CopyCode } from "@modrinth/ui";
|
||||
import { ButtonStyled, NewModal, ConfirmModal } from "@modrinth/ui";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<li v-if="fetchError" class="text-red">
|
||||
<p>Error details:</p>
|
||||
<CopyCode
|
||||
<UiCopyCode
|
||||
:text="(fetchError as ModrinthServersFetchError).message || 'Unknown error'"
|
||||
:copyable="false"
|
||||
:selectable="false"
|
||||
@@ -120,7 +120,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import Fuse from "fuse.js";
|
||||
import { HammerIcon, PlusIcon, SearchIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled, CopyCode } from "@modrinth/ui";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import type { Server, ModrinthServersFetchError } from "@modrinth/utils";
|
||||
import { reloadNuxtApp } from "#app";
|
||||
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
link="/settings"
|
||||
:label="formatMessage(commonSettingsMessages.appearance)"
|
||||
>
|
||||
<PaintbrushIcon />
|
||||
<PaintBrushIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem
|
||||
v-if="isStaging"
|
||||
@@ -82,7 +82,7 @@
|
||||
import {
|
||||
ServerIcon,
|
||||
GridIcon,
|
||||
PaintbrushIcon,
|
||||
PaintBrushIcon,
|
||||
UserIcon,
|
||||
ShieldIcon,
|
||||
KeyIcon,
|
||||
|
||||
@@ -422,7 +422,7 @@ import {
|
||||
} from "@modrinth/assets";
|
||||
import QrcodeVue from "qrcode.vue";
|
||||
import { ConfirmModal } from "@modrinth/ui";
|
||||
import GithubIcon from "assets/icons/auth/sso-github.svg";
|
||||
import GitHubIcon from "assets/icons/auth/sso-github.svg";
|
||||
import MicrosoftIcon from "assets/icons/auth/sso-microsoft.svg";
|
||||
import GoogleIcon from "assets/icons/auth/sso-google.svg";
|
||||
import SteamIcon from "assets/icons/auth/sso-steam.svg";
|
||||
@@ -583,7 +583,7 @@ const authProviders = [
|
||||
{
|
||||
id: "github",
|
||||
display: "GitHub",
|
||||
icon: GithubIcon,
|
||||
icon: GitHubIcon,
|
||||
},
|
||||
{
|
||||
id: "gitlab",
|
||||
|
||||
@@ -80,7 +80,6 @@
|
||||
projects
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="sumDownloads.toLocaleString()"
|
||||
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
|
||||
>
|
||||
<DownloadIcon class="h-6 w-6 text-secondary" />
|
||||
@@ -330,7 +329,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AdPlaceholder v-if="!auth.user" />
|
||||
<AdPlaceholder
|
||||
v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -385,12 +386,13 @@ const route = useNativeRoute();
|
||||
const auth = await useAuth();
|
||||
const cosmetics = useCosmetics();
|
||||
const tags = useTags();
|
||||
const flags = useFeatureFlags();
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const vintl = useVIntl();
|
||||
const { formatMessage } = vintl;
|
||||
|
||||
const formatCompactNumber = useCompactNumber(true);
|
||||
const formatCompactNumber = useCompactNumber();
|
||||
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
|
||||
|
||||
@@ -10,10 +10,6 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
instantMove: true,
|
||||
distance: 8,
|
||||
},
|
||||
"dismissable-prompt": {
|
||||
$extend: "dropdown",
|
||||
placement: "bottom-start",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user