You've already forked AstralRinth
forked from didirus/AstralRinth
Compare commits
196 Commits
ARF-v0.9.5
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
| 46d30e491a | |||
| 059c0618f1 | |||
| 7ef60fcafe | |||
| ec17e79014 | |||
| e351d674f4 | |||
| f555fa916a | |||
| dbe38cb4e7 | |||
| 2e40e26116 | |||
|
|
ae25a15abd | ||
|
|
0f755b94ce | ||
|
|
bcf46d440b | ||
|
|
526561f2de | ||
|
|
a8caa1afc3 | ||
|
|
98e9a8473d | ||
|
|
936395484e | ||
|
|
0c3e23db96 | ||
|
|
013ba4d86d | ||
|
|
93813c448c | ||
|
|
c20b869e62 | ||
|
|
56c556821b | ||
|
|
44267619b6 | ||
| 10afd673db | |||
|
|
90043fe84d | ||
|
|
a6a98ff63e | ||
|
|
911652133b | ||
|
|
cee1b5f522 | ||
|
|
62f5a23fcb | ||
| 5a10292add | |||
| 3f606a08aa | |||
| 2d5d747202 | |||
|
|
eb595cdc3e | ||
| 7516ff9e47 | |||
|
|
572cd065ed | ||
| df9bbe3ba0 | |||
| 362fd7f32a | |||
|
|
76dc8a0897 | ||
|
|
4723de6269 | ||
|
|
e15fa35bad | ||
|
|
2cc6bc8ce4 | ||
|
|
5d19d31b2c | ||
|
|
c1b95ede07 | ||
|
|
058185c7fd | ||
|
|
6fb125cf0f | ||
|
|
a945e9b005 | ||
|
|
b943638afb | ||
|
|
207dc0e2bb | ||
|
|
359fbd4738 | ||
| adf831dab9 | |||
| efeac22d14 | |||
| 591d98a9eb | |||
| 77472d9a09 | |||
| 789d666515 | |||
| d917bff6ef | |||
| 4e69cd8bde | |||
| b71e4cc6f9 | |||
| a56ab6adb9 | |||
| f1b67c9584 | |||
| 3d32640b83 | |||
| 6dfb599e14 | |||
| 332a543f66 | |||
| 1ef96c447e | |||
| 1ec92b5f97 | |||
| f0a4532051 | |||
|
|
f7700acce4 | ||
|
|
87a3e2d022 | ||
|
|
5d17663040 | ||
|
|
cff3c72f94 | ||
|
|
fadf475f06 | ||
|
|
7228499737 | ||
|
|
bca467a634 | ||
| 14f6450cf4 | |||
| 14bf06e4bd | |||
|
|
cb72d2ac80 | ||
|
|
3c79607d1f | ||
| 97bd18c7b3 | |||
| 8af0288274 | |||
| 167072de0c | |||
| 2df37be9a7 | |||
| 34d85a03b2 | |||
| 17cf5e3132 | |||
|
|
36ad1f16e4 | ||
|
|
5d4f334505 | ||
|
|
1fdb5ba748 | ||
| c5e67a5c6f | |||
| e2e21c1496 | |||
| 6da942ccbb | |||
|
|
26df6f51ef | ||
|
|
6caf794ae1 | ||
|
|
2692953e31 | ||
|
|
242fd713ab | ||
|
|
7a12c4d5e2 | ||
| 0ab4dec62d | |||
|
|
f256ef43c0 | ||
| 3ecb20afd6 | |||
| 1e10f24efe | |||
| 006fd7c7f5 | |||
| 1e8e001eb8 | |||
| 585935c799 | |||
|
|
e0cde2d6ff | ||
| a64c3360d2 | |||
|
|
e4e77dc0d2 | ||
|
|
8ba6467f21 | ||
| a2b2711204 | |||
|
|
088cb54317 | ||
| ab57926e44 | |||
| 35cd79727a | |||
|
|
c47bcf665d | ||
| fba296215d | |||
| d7e03fe2be | |||
| ba88244571 | |||
| d6d77256fe | |||
| 7449a209fb | |||
| 81852859ca | |||
| 9bd87cf986 | |||
|
|
bc90c27e27 | ||
|
|
c1be57773a | ||
|
|
315c68912c | ||
|
|
559d203996 | ||
|
|
54522518c3 | ||
|
|
bacb1561d5 | ||
|
|
b8521f926f | ||
|
|
b29672f4b4 | ||
|
|
a32fe6a41f | ||
|
|
0e35135093 | ||
|
|
31ecace083 | ||
|
|
e5b134f8f4 | ||
| f914ea1c7d | |||
| f55da799f1 | |||
| 7e58d7dd35 | |||
|
|
139a4863d1 | ||
|
|
8faea1663a | ||
|
|
ece8a07486 | ||
|
|
0030f35d0c | ||
|
|
1e24225350 | ||
|
|
e84a178586 | ||
|
|
0a83ed965e | ||
|
|
30035a9a1c | ||
|
|
512d456c66 | ||
|
|
bff26af465 | ||
|
|
f4d0f14cb6 | ||
|
|
55916b6bda | ||
|
|
a38e1dee1f | ||
|
|
ef76a81cd5 | ||
|
|
9dc5644264 | ||
|
|
8e35cf6957 | ||
|
|
ae1c3d6531 | ||
|
|
4964c8d373 | ||
|
|
497b2e977e | ||
|
|
f95d0d78f2 | ||
|
|
94a7d13af8 | ||
|
|
3a10e63756 | ||
|
|
238138d56e | ||
|
|
1846c59733 | ||
|
|
f1207f0a3a | ||
|
|
26e964174d | ||
|
|
897418ead3 | ||
|
|
eef09e1ffe | ||
|
|
fdb2b1195e | ||
|
|
4b3e036e2a | ||
|
|
3233e7fc54 | ||
|
|
dd98a1316a | ||
|
|
e5030a8fbe | ||
|
|
f549560e47 | ||
|
|
33d26238ce | ||
|
|
bcec478a64 | ||
|
|
8971d39683 | ||
|
|
1c1631f131 | ||
|
|
14b1ff79e0 | ||
|
|
479aaf503b | ||
|
|
240cccf8a1 | ||
|
|
2599dc2672 | ||
|
|
e2668f20b7 | ||
|
|
cf767c7ef2 | ||
|
|
14a7787e3d | ||
|
|
db963eb5de | ||
|
|
a1812cd954 | ||
|
|
5ed9d1749a | ||
|
|
17ca209862 | ||
|
|
03192c1dfd | ||
|
|
6f03fae233 | ||
|
|
22fc0c994d | ||
|
|
a1ccbc5757 | ||
|
|
053cf10198 | ||
|
|
257efd8ad7 | ||
|
|
b75cfc063b | ||
|
|
2d8420131d | ||
|
|
c793b68aed | ||
|
|
47af459f24 | ||
|
|
f10e0f2bf1 | ||
|
|
569d60cb57 | ||
|
|
74d36a6a2d | ||
|
|
ced073d26c | ||
|
|
cc34e69524 | ||
|
|
d4864deac5 | ||
|
|
125207880d | ||
|
|
a8f17f40f5 |
@@ -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"]
|
||||
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
|
||||
|
||||
[build]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
||||
|
||||
1
.dockerignore
Symbolic link
1
.dockerignore
Symbolic link
@@ -0,0 +1 @@
|
||||
.gitignore
|
||||
@@ -14,5 +14,5 @@ max_line_length = 100
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.rs]
|
||||
indent_size = 4
|
||||
[*.{rs,java,kts}]
|
||||
indent_size = 4
|
||||
|
||||
34
.gitattributes
vendored
34
.gitattributes
vendored
@@ -1 +1,35 @@
|
||||
* 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
|
||||
|
||||
153
.github/workflows/astralrinth-build.yml
vendored
Normal file
153
.github/workflows/astralrinth-build.yml
vendored
Normal file
@@ -0,0 +1,153 @@
|
||||
name: AstralRinth App build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- feature*
|
||||
tags:
|
||||
- 'v*'
|
||||
paths:
|
||||
- .github/workflows/astralrinth-build.yml
|
||||
- 'apps/app/**'
|
||||
- 'apps/app-frontend/**'
|
||||
- 'packages/app-lib/**'
|
||||
- 'packages/app-macros/**'
|
||||
- 'packages/assets/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/utils/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# platform: [macos-latest, windows-latest, ubuntu-latest]
|
||||
platform: [windows-latest, ubuntu-latest]
|
||||
include:
|
||||
# - platform: macos-latest
|
||||
# artifact-target-name: universal-apple-darwin
|
||||
- platform: windows-latest
|
||||
artifact-target-name: x86_64-pc-windows-msvc
|
||||
- platform: ubuntu-latest
|
||||
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: 🔍 Validate Git config does not introduce CRLF
|
||||
shell: bash
|
||||
run: |
|
||||
echo "🔍 Checking Git config for CRLF settings..."
|
||||
|
||||
autocrlf=$(git config --get core.autocrlf || echo "unset")
|
||||
eol_setting=$(git config --get core.eol || echo "unset")
|
||||
|
||||
echo "core.autocrlf = $autocrlf"
|
||||
echo "core.eol = $eol_setting"
|
||||
|
||||
if [ "$autocrlf" = "true" ]; then
|
||||
echo "⚠️ WARNING: core.autocrlf is set to 'true'. Consider setting it to 'input' or 'false'."
|
||||
fi
|
||||
|
||||
if [ "$eol_setting" = "crlf" ]; then
|
||||
echo "⚠️ WARNING: core.eol is set to 'crlf'. Consider unsetting it or setting to 'lf'."
|
||||
fi
|
||||
|
||||
- name: 🔍 Check migration files line endings (LF only)
|
||||
shell: bash
|
||||
run: |
|
||||
echo "🔍 Scanning migration SQL files for CR characters (\\r)..."
|
||||
if grep -Iq $'\r' packages/app-lib/migrations/*.sql; then
|
||||
echo "❌ ERROR: Some migration files contain CR (\\r) characters — expected only LF line endings."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All migration files use LF line endings"
|
||||
|
||||
- name: 🧰 Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
target: ${{ matrix.artifact-target-name }}
|
||||
|
||||
- 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: matrix.platform == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -yq \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
xdg-utils \
|
||||
openjdk-11-jdk
|
||||
|
||||
- name: 💨 Setup Turbo cache
|
||||
uses: rharkor/caching-for-turbo@v1.8
|
||||
|
||||
- name: 🧰 Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: ✍️ Set up Windows code signing (jsign)
|
||||
if: matrix.platform == 'windows-latest' && env.SIGN_WINDOWS_BINARIES == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
choco install jsign --ignore-dependencies
|
||||
|
||||
- name: 🗑️ Clean up cached bundles
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf target/release/bundle
|
||||
rm -rf target/*/release/bundle || true
|
||||
|
||||
# - name: 🔨 Build macOS app
|
||||
# if: matrix.platform == 'macos-latest'
|
||||
# run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
|
||||
# env:
|
||||
# TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: 🔨 Build Linux app
|
||||
if: matrix.platform == 'ubuntu-latest'
|
||||
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: 🔨 Build Windows app
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
|
||||
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: 📤 Upload app bundles
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: App bundle (${{ matrix.artifact-target-name }})
|
||||
path: |
|
||||
target/release/bundle/appimage/AstralRinth App_*.AppImage*
|
||||
target/release/bundle/deb/AstralRinth App_*.deb*
|
||||
target/release/bundle/rpm/AstralRinth App-*.rpm*
|
||||
target/universal-apple-darwin/release/bundle/macos/AstralRinth App.app.tar.gz*
|
||||
target/universal-apple-darwin/release/bundle/dmg/AstralRinth App_*.dmg*
|
||||
target/release/bundle/nsis/AstralRinth App_*-setup.exe*
|
||||
target/release/bundle/nsis/AstralRinth App_*-setup.nsis.zip*
|
||||
151
.github/workflows/theseus-release.yml
vendored
151
.github/workflows/theseus-release.yml
vendored
@@ -1,151 +0,0 @@
|
||||
name: 'AstralRinth App Build'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feature*
|
||||
tags:
|
||||
- 'build*'
|
||||
- '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:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [macos-latest, windows-latest, ubuntu-latest]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Rust setup (mac)
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
|
||||
- name: Rust setup
|
||||
if: "!startsWith(matrix.platform, 'macos')"
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Setup rust cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
target/**
|
||||
!target/*/release/bundle/*/*.dmg
|
||||
!target/*/release/bundle/*/*.app.tar.gz
|
||||
!target/*/release/bundle/*/*.app.tar.gz.sig
|
||||
!target/release/bundle/*/*.dmg
|
||||
!target/release/bundle/*/*.app.tar.gz
|
||||
!target/release/bundle/*/*.app.tar.gz.sig
|
||||
|
||||
!target/release/bundle/appimage/*.AppImage
|
||||
!target/release/bundle/appimage/*.AppImage.tar.gz
|
||||
!target/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
!target/release/bundle/deb/*.deb
|
||||
!target/release/bundle/rpm/*.rpm
|
||||
|
||||
!target/release/bundle/msi/*.msi
|
||||
!target/release/bundle/msi/*.msi.zip
|
||||
!target/release/bundle/msi/*.msi.zip.sig
|
||||
|
||||
!target/release/bundle/nsis/*.exe
|
||||
!target/release/bundle/nsis/*.nsis.zip
|
||||
!target/release/bundle/nsis/*.nsis.zip.sig
|
||||
key: ${{ runner.os }}-rust-target-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-rust-target-
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Install pnpm via corepack
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare --activate
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- 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-
|
||||
|
||||
- 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 xdg-utils
|
||||
|
||||
- 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 }}
|
||||
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@v3
|
||||
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>
|
||||
1269
Cargo.lock
generated
1269
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
48
Cargo.toml
48
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.24", default-features = false }
|
||||
async-compression = { version = "0.4.25", default-features = false }
|
||||
async-recursion = "1.1.1"
|
||||
async-stripe = { version = "0.41.0", default-features = false, features = [
|
||||
"runtime-tokio-hyper-rustls",
|
||||
@@ -37,6 +37,7 @@ 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"
|
||||
@@ -47,6 +48,7 @@ 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"
|
||||
@@ -60,10 +62,17 @@ flate2 = "1.1.2"
|
||||
fs4 = { version = "0.13.1", default-features = false }
|
||||
futures = { version = "0.3.31", default-features = false }
|
||||
futures-util = "0.3.31"
|
||||
hashlink = "0.10.0"
|
||||
heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
hickory-resolver = "0.25.2"
|
||||
hmac = "0.12.1"
|
||||
hyper-tls = "0.6.0"
|
||||
hyper-rustls = { version = "0.27.7", default-features = false, features = [
|
||||
"http1",
|
||||
"native-tokio",
|
||||
"ring",
|
||||
"tls12",
|
||||
] }
|
||||
hyper-util = "0.1.14"
|
||||
iana-time-zone = "0.1.63"
|
||||
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
|
||||
@@ -89,6 +98,7 @@ 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"
|
||||
@@ -96,8 +106,9 @@ 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.19", default-features = false }
|
||||
rust_decimal = { version = "1.37.1", features = [
|
||||
reqwest = { version = "0.12.20", default-features = false }
|
||||
rgb = "0.8.50"
|
||||
rust_decimal = { version = "1.37.2", features = [
|
||||
"serde-with-float",
|
||||
"serde-with-str",
|
||||
] }
|
||||
@@ -108,7 +119,7 @@ rust-s3 = { version = "0.35.1", default-features = false, features = [
|
||||
"tokio-rustls-tls",
|
||||
] }
|
||||
rusty-money = "0.4.1"
|
||||
sentry = { version = "0.38.1", default-features = false, features = [
|
||||
sentry = { version = "0.41.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
"debug-images",
|
||||
@@ -116,13 +127,13 @@ sentry = { version = "0.38.1", default-features = false, features = [
|
||||
"reqwest",
|
||||
"rustls",
|
||||
] }
|
||||
sentry-actix = "0.38.1"
|
||||
sentry-actix = "0.41.0"
|
||||
serde = "1.0.219"
|
||||
serde_bytes = "0.11.17"
|
||||
serde_cbor = "0.11.2"
|
||||
serde_ini = "0.2.0"
|
||||
serde_json = "1.0.140"
|
||||
serde_with = "3.12.0"
|
||||
serde_with = "3.13.0"
|
||||
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
|
||||
sha1 = "0.10.6"
|
||||
sha1_smol = { version = "1.0.1", features = ["std"] }
|
||||
@@ -131,18 +142,19 @@ 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.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 = [
|
||||
tauri = "2.6.1"
|
||||
tauri-build = "2.3.0"
|
||||
tauri-plugin-deep-link = "2.4.0"
|
||||
tauri-plugin-dialog = "2.3.0"
|
||||
tauri-plugin-http = "2.5.0"
|
||||
tauri-plugin-opener = "2.4.0"
|
||||
tauri-plugin-os = "2.3.0"
|
||||
tauri-plugin-single-instance = "2.3.0"
|
||||
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
"zip",
|
||||
] }
|
||||
tauri-plugin-window-state = "2.2.2"
|
||||
tauri-plugin-window-state = "2.3.0"
|
||||
tempfile = "3.20.0"
|
||||
theseus = { path = "packages/app-lib" }
|
||||
thiserror = "2.0.12"
|
||||
@@ -165,7 +177,7 @@ whoami = "1.6.0"
|
||||
winreg = "0.55.0"
|
||||
woothee = "0.13.0"
|
||||
yaserde = "0.12.0"
|
||||
zip = { version = "4.0.0", default-features = false, features = [
|
||||
zip = { version = "4.2.0", default-features = false, features = [
|
||||
"bzip2",
|
||||
"deflate",
|
||||
"deflate64",
|
||||
@@ -212,7 +224,7 @@ wildcard_dependencies = "warn"
|
||||
warnings = "deny"
|
||||
|
||||
[patch.crates-io]
|
||||
wry = { git = "https://github.com/modrinth/wry", rev = "cafdaa9" }
|
||||
wry = { git = "https://github.com/modrinth/wry", rev = "21db186" }
|
||||
|
||||
# Optimize for speed and reduce size on release builds
|
||||
[profile.release]
|
||||
|
||||
159
README.md
159
README.md
@@ -1,76 +1,123 @@
|
||||
# Navigation in this README
|
||||
- [Install instructions](#install-instructions)
|
||||
- [Features](#features)
|
||||
- [Getting started](#getting-started)
|
||||
- [Disclaimer](#disclaimer)
|
||||
- [Donate](#support-our-project-crypto-wallets)
|
||||
# 📘 Navigation
|
||||
|
||||
- [🔧 Install Instructions](#install-instructions)
|
||||
- [✨ Features](#features)
|
||||
- [🚀 Getting Started](#getting-started)
|
||||
- [⚠️ Disclaimer](#disclaimer)
|
||||
- [💰 Donate](#support-our-project-crypto-wallets)
|
||||
|
||||
## Other languages
|
||||
> [Русский](readme/ru_ru/README.md)
|
||||
|
||||
## Support channel
|
||||
> [Telegram](https://me.astralium.su/ref/telegram_channel)
|
||||
|
||||
---
|
||||
|
||||
# About Project
|
||||
|
||||
## AstralRinth • Empowering Your Minecraft Adventure
|
||||
Welcome to AR • Fork of Modrinth, the ultimate game launcher designed to enhance your Minecraft experience through the Modrinth platform and their API. Whether you're a graphical interface enthusiast, or a developer integrating Modrinth projects, Theseus core is your gateway to a new level of Minecraft gaming.
|
||||
## **AstralRinth • Empowering Your Minecraft Adventure**
|
||||
|
||||
## About Software
|
||||
Introducing AstralRinth, a specialized variant of Theseus dedicated to implementing offline authorization for an even more flexible and user-centric Minecraft Modrinth experience. Roam the Minecraft realms without the constraints of online authentication, thanks to AstralRinth.
|
||||
Welcome to **AstralRinth (AR)** — a powerful fork of Modrinth, reimagined to enhance your Minecraft journey. Whether you're a GUI enthusiast or a developer building with Modrinth’s API, **Theseus Core** is your launchpad into a new era of Minecraft gameplay.
|
||||
|
||||
## AR • Unlocking Minecraft's Boundless Horizon
|
||||
Dive into the extraordinary world of AstralRinth, a fork of the original project with a unique focus on providing a free trial experience for Minecraft, all without the need for a license. Currently boasting:
|
||||
- *Recently, improved integration with the Git Astralium API has been added.*
|
||||
|
||||
# Install instructions
|
||||
- To install our application, you need to download a file for your operating system from our available releases or development builds • [Download variants here](https://git.astralium.su/didirus/AstralRinth/releases)
|
||||
- After you have downloaded the required executable file or archive, then open it
|
||||
## **About the Software**
|
||||
|
||||
### Downloadable file extensions
|
||||
- `.msi` format for Windows OS system _(Supported popular latest versions of Microsoft Windows)_
|
||||
- `.dmg` format for MacOS system _(Works on Macos Ventura / Sonoma / Sequoia, but it should be works on older OS builds)_
|
||||
- `.deb` format for Linux OS systems _(Since there are quite a few distributions, we do not guarantee
|
||||
**AstralRinth** is a dedicated branch of the Theseus project, focused on **offline authentication**, offering you more flexibility and control. Play Minecraft without the need for constant online verification — a user-first approach to modern modded gaming.
|
||||
|
||||
### Installation subjects
|
||||
- Builds in releases that are signed with the following prefixes are not recommended for installation and may contain errors:
|
||||
- `dev`
|
||||
- `nightly`
|
||||
- `dirty`
|
||||
- `dirty-dev`
|
||||
- `dirty-nightly`
|
||||
- `dirty_dev`
|
||||
- `dirty_nightly`
|
||||
- Auto-updating takes place through parsing special versions from releases, so we also distribute clean types of `.msi, .dmg and .deb`
|
||||
## **AR • Unlocking Minecraft's Boundless Horizon**
|
||||
|
||||
This unique fork introduces a **free trial Minecraft experience**, bypassing license checks while maintaining rich functionality. Currently includes:
|
||||
|
||||
---
|
||||
|
||||
# Install Instructions
|
||||
|
||||
To install the launcher:
|
||||
|
||||
1. Visit the [releases page](https://git.astralium.su/didirus/AstralRinth/releases) to download the correct version for your system.
|
||||
2. Run the downloaded file or extract and launch it, depending on the format.
|
||||
|
||||
### Downloadable File Extensions
|
||||
|
||||
| Extension | OS | Notes |
|
||||
| --------- | ------- | --------------------------------------------------------------------- |
|
||||
| `.msi` | Windows | Supported on all recent Windows versions |
|
||||
| `.dmg` | macOS | Works on Ventura, Sonoma, Sequoia _(may also support older versions)_ |
|
||||
| `.deb` | Linux | Basic support; compatibility may vary by distribution |
|
||||
|
||||
### Installation Warnings
|
||||
|
||||
Avoid using builds with these prefixes — they may be unstable or experimental:
|
||||
|
||||
- `dev`
|
||||
- `nightly`
|
||||
- `dirty`
|
||||
- `dirty-dev`
|
||||
- `dirty-nightly`
|
||||
- `dirty_dev`
|
||||
- `dirty_nightly`
|
||||
|
||||
---
|
||||
|
||||
# Features
|
||||
|
||||
### Featured enhancement in AR
|
||||
- AstralRinth offers a range of authorization options, giving users the flexibility to log in with valid licenses or even a pirate account without auth credentials breaks (_Unlike MultiMC Cracked and similar software_). Experience Minecraft on your terms, breaking free from traditional licensing constraints (_Popular in Russian Federation_).
|
||||
> _The launcher provides an opportunity to use the well-known Modrinth, but with an improved user experience._
|
||||
|
||||
### Easy to use
|
||||
- Using the launcher is intuitive, any user can figure it out.
|
||||
## Included exclusive features
|
||||
|
||||
### Update notifies
|
||||
- We have implemented notifications about the release of new updates on our Git. The launcher can also download them for you and try to install them.
|
||||
- No ads in the entire launcher.
|
||||
- Custom `.svg` vector icons for a distinct UI.
|
||||
- Improved compatibility with both licensed and pirate accounts.
|
||||
- Use **official microsoft accounts** or **offline/pirate accounts** — login won't break.
|
||||
- Supports license-free access for testing or personal use.
|
||||
- No dependence on official authentication services.
|
||||
- Discord Rich Presence integration:
|
||||
- Dynamic status messages.
|
||||
- In-game timer and AFK counter.
|
||||
- Strict disabling of statistics and other Modrinth metrics.
|
||||
- Optimized archive/package size.
|
||||
- Integrated update fetcher for seamless version management.
|
||||
- Built-in update alerts for new versions posted on Git Astralium.
|
||||
- Automatic download and installation capabilities.
|
||||
- Database migration fixes, when error occurred (Interactive Mode) (Modrinth issue)
|
||||
- ElyBy skin system integration (AuthLib / Java)
|
||||
|
||||
### Enhancements
|
||||
- Custom .SVG vectors for a personalized touch.
|
||||
- Improved compatibility for both pirate and licensed accounts.
|
||||
- Beautiful Discord RPC with random messages while playing, along with an in-game timer and AFK counter.
|
||||
- Forced disabling of statistics collection (modrinch metrics) with a hard patch from AstralRinth, ensuring it remains deactivated regardless of the configuration setting.
|
||||
- Removal of advertisements from all launcher views.
|
||||
- Optimization of packages (archives).
|
||||
- Integrated update fetching feature
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
To begin your AstralRinth adventure, follow these steps:
|
||||
1. **Download Your OS Version**: Head over to our [releases page](https://git.astralium.su/didirus/AstralRinth/releases/) to find the right file for your operating system.
|
||||
- **Choosing the Correct File**: Ensure you select the file that matches your OS requirements.
|
||||
- [**How select file**](#downloadable-file-extensions)
|
||||
- [**How select release**](#installation-subjects)
|
||||
2. **Authentication**: Log in with a valid license or, for testing, try using a pirate account to see AstralRinth in action.
|
||||
3. **Launch Minecraft**: Start your journey by launching Minecraft through AstralRinth and enjoy the adventures that await.
|
||||
- **Choosing java installation**: The launcher will try to automatically detect the recommended JVM version for running the game, but you can configure everything in the launcher settings.
|
||||
|
||||
To begin using AstralRinth:
|
||||
|
||||
1. **Download Your OS Version**
|
||||
|
||||
- Go to the [releases page](https://git.astralium.su/didirus/AstralRinth/releases)
|
||||
- [How to choose a file](#downloadable-file-extensions)
|
||||
- [How to choose a release](#installation-warnings)
|
||||
|
||||
2. **Log In**
|
||||
|
||||
- Use your official Mojang/Microsoft account, or test using a non-licensed account.
|
||||
|
||||
3. **Launch Minecraft**
|
||||
- Start Minecraft from the launcher.
|
||||
- The launcher will auto-detect the recommended JVM version.
|
||||
- You can also configure Java manually in the settings.
|
||||
|
||||
---
|
||||
|
||||
# Disclaimer
|
||||
- AstralRinth is a project intended for experimentation and educational purposes only. It does not endorse or support piracy, and users are encouraged to obtain valid licenses for a fully-supported Minecraft experience.
|
||||
- Users are reminded to respect licensing agreements and support the developers of Minecraft.
|
||||
|
||||
# Support our Project (Crypto Wallets)
|
||||
- **AstralRinth** is intended **solely for educational and experimental use**.
|
||||
- We **do not condone piracy** — users are encouraged to purchase a legitimate Minecraft license.
|
||||
- Respect all relevant licensing agreements and support Minecraft developers.
|
||||
|
||||
---
|
||||
|
||||
# Support Our Project (Crypto Wallets)
|
||||
|
||||
If you'd like to support development, you can donate via the following crypto wallets:
|
||||
|
||||
- BTC (Telegram): 14g6asNYzcUoaQtB8B2QGKabgEvn55wfLj
|
||||
- USDT TRC20 (Telegram): TMSmv1D5Fdf4fipUpwBCdh16WevrV45vGr
|
||||
- TONCOIN (Telegram): UQAqUJ2_hVBI6k_gPyfp_jd-1K0OS61nIFPZuJWN9BwGAvKe
|
||||
- TONCOIN (Telegram): UQAqUJ2_hVBI6k_gPyfp_jd-1K0OS61nIFPZuJWN9BwGAvKe
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
**/dist
|
||||
*.gltf
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@modrinth/app-frontend",
|
||||
"private": true,
|
||||
"version": "0.9.5",
|
||||
"version": "1.0.0-local",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -20,16 +20,20 @@
|
||||
"@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,8 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowBigUpDashIcon,
|
||||
ChangeSkinIcon,
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
HomeIcon,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
SettingsIcon,
|
||||
WorldIcon,
|
||||
XIcon,
|
||||
NewspaperIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
ButtonStyled,
|
||||
Notifications,
|
||||
OverflowMenu,
|
||||
useRelativeTime,
|
||||
NewsArticleCard,
|
||||
} from '@modrinth/ui'
|
||||
import { useLoading, useTheming } from '@/store/state'
|
||||
// import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
||||
@@ -40,7 +42,7 @@ import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
|
||||
import { handleError, useNotifications } from '@/store/notifications.js'
|
||||
import { command_listener, warning_listener } from '@/helpers/events.js'
|
||||
import { type } from '@tauri-apps/plugin-os'
|
||||
import { getOS, isDev, restartApp } from '@/helpers/utils.js'
|
||||
import { getOS, isDev } from '@/helpers/utils.js'
|
||||
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
@@ -62,14 +64,16 @@ 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()
|
||||
// [AR] Feature
|
||||
import { getRemote, updateState } from '@/helpers/update.js'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -98,6 +102,7 @@ const isMaximized = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await useCheckDisableMouseover()
|
||||
await getRemote(false) // [AR] Check for updates
|
||||
|
||||
document.querySelector('body').addEventListener('click', handleClick)
|
||||
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
|
||||
@@ -160,11 +165,11 @@ async function setupApp() {
|
||||
|
||||
initAnalytics()
|
||||
if (!telemetry) {
|
||||
console.info("[AR] Telemetry disabled by default (Hard patched).")
|
||||
console.info("[AR] • Telemetry disabled by default (Hard patched).")
|
||||
optOutAnalytics()
|
||||
}
|
||||
if (!personalized_ads) {
|
||||
console.info("[AR] Personalized ads disabled by default (Hard patched).")
|
||||
console.info("[AR] • Personalized ads disabled by default (Hard patched).")
|
||||
}
|
||||
|
||||
if (dev) debugAnalytics()
|
||||
@@ -187,31 +192,53 @@ async function setupApp() {
|
||||
}),
|
||||
)
|
||||
|
||||
//useFetch(
|
||||
// `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||
// 'criticalAnnouncements',
|
||||
// true,
|
||||
//)
|
||||
// .then((res) => {
|
||||
// if (res && res.header && res.body) {
|
||||
// criticalErrorMessage.value = res
|
||||
// }
|
||||
// })
|
||||
// .catch(() => {
|
||||
// console.log(
|
||||
// `No critical announcement found at https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||
// )
|
||||
// })
|
||||
/// [AR] Patch
|
||||
// useFetch(
|
||||
// `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||
// 'criticalAnnouncements',
|
||||
// true,
|
||||
// )
|
||||
// .then((response) => response.json())
|
||||
// .then((res) => {
|
||||
// if (res && res.header && res.body) {
|
||||
// criticalErrorMessage.value = res
|
||||
// }
|
||||
// })
|
||||
// .catch(() => {
|
||||
// console.log(
|
||||
// `No critical announcement found at https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||
// )
|
||||
// })
|
||||
|
||||
useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => {
|
||||
if (res && res.articles) {
|
||||
news.value = res.articles
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -319,6 +346,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const accounts = ref(null)
|
||||
provide('accountsCard', accounts)
|
||||
|
||||
command_listener(handleCommand)
|
||||
async function handleCommand(e) {
|
||||
@@ -414,6 +442,9 @@ function handleAuxClick(e) {
|
||||
>
|
||||
<CompassIcon />
|
||||
</NavButton>
|
||||
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
|
||||
<ChangeSkinIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="'Library'"
|
||||
to="/library"
|
||||
@@ -438,12 +469,20 @@ function handleAuxClick(e) {
|
||||
<PlusIcon />
|
||||
</NavButton>
|
||||
<div class="flex flex-grow"></div>
|
||||
<NavButton v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()">
|
||||
<!-- [AR] TODO -->
|
||||
<!-- <NavButton v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()">
|
||||
<DownloadIcon />
|
||||
</NavButton>
|
||||
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
|
||||
<SettingsIcon />
|
||||
</NavButton>
|
||||
</NavButton> -->
|
||||
<template v-if="updateState">
|
||||
<NavButton class="neon-icon pulse" v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
|
||||
<SettingsIcon />
|
||||
</NavButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
|
||||
<SettingsIcon />
|
||||
</NavButton>
|
||||
</template>
|
||||
<ButtonStyled v-if="credentials" type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
@@ -474,13 +513,13 @@ function handleAuxClick(e) {
|
||||
<!-- <ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" /> -->
|
||||
<div class="flex items-center gap-1 ml-3">
|
||||
<button
|
||||
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||
@click="router.back()"
|
||||
>
|
||||
<LeftArrowIcon />
|
||||
</button>
|
||||
<button
|
||||
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||
@click="router.forward()"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
@@ -594,34 +633,20 @@ function handleAuxClick(e) {
|
||||
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
|
||||
</suspense>
|
||||
</div>
|
||||
<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]"
|
||||
<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"
|
||||
/>
|
||||
</template>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<a href="https://modrinth.com/news" target="_blank" class="my-4">
|
||||
<NewspaperIcon /> View all news
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -646,6 +671,9 @@ function handleAuxClick(e) {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../../../packages/assets/styles/neon-icon.scss';
|
||||
@import '../../../packages/assets/styles/neon-text.scss';
|
||||
|
||||
.window-controls {
|
||||
z-index: 20;
|
||||
display: none;
|
||||
|
||||
BIN
apps/app-frontend/src/assets/skins/herobrine.png
Normal file
BIN
apps/app-frontend/src/assets/skins/herobrine.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/app-frontend/src/assets/skins/steve.png
Normal file
BIN
apps/app-frontend/src/assets/skins/steve.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -136,7 +136,7 @@ const filteredResults = computed(() => {
|
||||
|
||||
if (sortBy.value === 'Game version') {
|
||||
instances.sort((a, b) => {
|
||||
return a.game_version.localeCompare(b.game_version)
|
||||
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -213,6 +213,17 @@ const filteredResults = computed(() => {
|
||||
instanceMap.set(entry[0], entry[1])
|
||||
})
|
||||
}
|
||||
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
|
||||
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
|
||||
if (group.value === 'Game version') {
|
||||
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
|
||||
return a[0].localeCompare(b[0], undefined, { numeric: true })
|
||||
})
|
||||
instanceMap.clear()
|
||||
sortedEntries.forEach((entry) => {
|
||||
instanceMap.set(entry[0], entry[1])
|
||||
})
|
||||
}
|
||||
|
||||
return instanceMap
|
||||
})
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="mode !== 'isolated'"
|
||||
ref="button"
|
||||
<div v-if="mode !== 'isolated'" ref="button"
|
||||
class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
|
||||
:class="{ expanded: mode === 'expanded' }"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<Avatar
|
||||
size="36px"
|
||||
:src="
|
||||
selectedAccount
|
||||
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
|
||||
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
"
|
||||
/>
|
||||
:class="{ expanded: mode === 'expanded' }" @click="toggleMenu">
|
||||
<Avatar size="36px" :src="selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
" />
|
||||
<div class="flex flex-col w-full">
|
||||
<span>
|
||||
<component v-if="selectedAccount" :is="getAccountType(selectedAccount)" class="vector-icon" />
|
||||
{{ selectedAccount ? selectedAccount.username : 'Select account' }}
|
||||
<component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" />
|
||||
{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}
|
||||
</span>
|
||||
<span class="text-secondary text-xs">Minecraft account</span>
|
||||
</div>
|
||||
@@ -27,79 +17,175 @@
|
||||
<Card v-if="showCard || mode === 'isolated'" ref="card" class="account-card"
|
||||
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }">
|
||||
<div v-if="selectedAccount" class="selected account">
|
||||
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.username}/128`" />
|
||||
<Avatar size="xs" :src="avatarUrl" />
|
||||
<div>
|
||||
<h4>
|
||||
<component :is="getAccountType(selectedAccount)" class="vector-icon" /> {{ selectedAccount.username }}
|
||||
<component :is="getAccountType(selectedAccount)" class="vector-icon" /> {{ selectedAccount.profile.name }}
|
||||
</h4>
|
||||
<p>Selected</p>
|
||||
</div>
|
||||
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.id)">
|
||||
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.profile.id)">
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="login-section account">
|
||||
<h4>Not signed in</h4>
|
||||
<Button v-tooltip="'Log in'" icon-only @click="login()">
|
||||
<MicrosoftIcon />
|
||||
<Button v-tooltip="'Log via Microsoft'" :disabled="microsoftLoginDisabled" icon-only @click="login()">
|
||||
<MicrosoftIcon v-if="!microsoftLoginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
</Button>
|
||||
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()">
|
||||
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
|
||||
<PirateIcon />
|
||||
</Button>
|
||||
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElybyLoginModal()">
|
||||
<ElyByIcon v-if="!elybyLoginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="displayAccounts.length > 0" class="account-group">
|
||||
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
|
||||
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
|
||||
<Button class="option account" @click="setAccount(account)">
|
||||
<Avatar :src="`https://mc-heads.net/avatar/${account.username}/128`" class="icon" />
|
||||
<Avatar :src="getAccountAvatarUrl(account)" class="icon" />
|
||||
<p class="account-type">
|
||||
<component :is="getAccountType(account)" class="vector-icon" />
|
||||
{{ account.username }}
|
||||
{{ account.profile.name }}
|
||||
</p>
|
||||
</Button>
|
||||
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
|
||||
<Button v-tooltip="'Log out'" icon-only @click="logout(account.profile.id)">
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="accounts.length > 0" class="login-section account centered">
|
||||
<Button v-tooltip="'Log in'" icon-only @click="login()">
|
||||
<MicrosoftIcon />
|
||||
<Button v-tooltip="'Log via Microsoft'" icon-only @click="login()">
|
||||
<MicrosoftIcon v-if="!microsoftLoginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
</Button>
|
||||
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()">
|
||||
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
|
||||
<PirateIcon />
|
||||
</Button>
|
||||
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElybyLoginModal()">
|
||||
<ElyByIcon v-if="!elybyLoginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</transition>
|
||||
<ModalWrapper ref="loginOfflineModal" class="modal" header="Add new offline account">
|
||||
<div class="modal-body">
|
||||
<div class="label">Enter offline username</div>
|
||||
<input type="text" v-model="playerName" placeholder="Provide offline player name" />
|
||||
<Button icon-only color="secondary" @click="offlineLoginFinally()">
|
||||
Continue
|
||||
</Button>
|
||||
<ModalWrapper ref="addElybyModal" class="modal" header="Authenticate with Ely.by">
|
||||
<ModalWrapper ref="requestElybyTwoFactorCodeModal" class="modal"
|
||||
header="Ely.by requested 2FA code for authentication">
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="label">Enter your 2FA code</label>
|
||||
<input v-model="elybyTwoFactorCode" type="text" placeholder="Your 2FA code here..." class="input" />
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button icon-only color="primary" class="continue-button" @click="addElybyProfile()">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="label">Enter your player name or email (preferred)</label>
|
||||
<input v-model="elybyLogin" type="text" placeholder="Your player name or email here..." class="input" />
|
||||
<label class="label">Enter your password</label>
|
||||
<input v-model="elybyPassword" type="password" placeholder="Your password here..." class="input" />
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button icon-only color="primary" class="continue-button" @click="addElybyProfile()">
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="loginErrorModal" class="modal" header="Error while proceed">
|
||||
<div class="modal-body">
|
||||
<div class="label">Error occurred while adding offline account</div>
|
||||
<Button color="primary" @click="retryOfflineLogin()">
|
||||
Try again
|
||||
</Button>
|
||||
<ModalWrapper ref="addOfflineModal" class="modal" header="Add new offline account">
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="label">Enter your player name</label>
|
||||
<input v-model="offlinePlayerName" type="text" placeholder="Your player name here..." class="input" />
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button icon-only color="primary" class="continue-button" @click="addOfflineProfile()">
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="unexpectedErrorModal" class="modal" header="Ошибка">
|
||||
<ModalWrapper
|
||||
ref="authenticationElybyErrorModal"
|
||||
class="modal"
|
||||
header="Error while proceeding authentication event with Ely.by">
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="text-base font-medium text-red-700">
|
||||
An error occurred while logging in.
|
||||
</label>
|
||||
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button color="primary" class="retry-button" @click="retryAddElybyProfile">
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="inputElybyErrorModal" class="modal" header="Error while proceeding input event with Ely.by">
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="text-base font-medium text-red-700">
|
||||
An error occurred while adding the Ely.by account. Please follow the instructions below.
|
||||
</label>
|
||||
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li>Check that you have entered the correct player name or email.</li>
|
||||
<li>Check that you have entered the correct password.</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button color="primary" class="retry-button" @click="retryAddElybyProfile">
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="inputErrorModal" class="modal" header="Error while proceeding input event with offline account">
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="text-base font-medium text-red-700">
|
||||
An error occurred while adding the offline account. Please follow the instructions below.
|
||||
</label>
|
||||
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li>Check that you have entered the correct player name.</li>
|
||||
<li>
|
||||
Player name must be at least {{ minOfflinePlayerNameLength }} characters long and no more than
|
||||
{{ maxOfflinePlayerNameLength }} characters.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button color="primary" class="retry-button" @click="retryAddOfflineProfile">
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="exceptionErrorModal" class="modal" header="Unexpected error occurred">
|
||||
<div class="modal-body">
|
||||
<div class="label">Unexcepted error</div>
|
||||
<label class="label">An unexpected error has occurred. Please try again later.</label>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon, PirateIcon as Offline, MicrosoftIcon as License, MicrosoftIcon, PirateIcon } from '@modrinth/assets'
|
||||
import {
|
||||
DropdownIcon,
|
||||
TrashIcon,
|
||||
PirateIcon as Offline,
|
||||
MicrosoftIcon as License,
|
||||
ElyByIcon as Elyby,
|
||||
MicrosoftIcon,
|
||||
PirateIcon,
|
||||
ElyByIcon,
|
||||
SpinnerIcon
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, Button, Card } from '@modrinth/ui'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||
import {
|
||||
elyby_auth_authenticate,
|
||||
elyby_login,
|
||||
offline_login,
|
||||
users,
|
||||
remove_user,
|
||||
@@ -111,7 +197,9 @@ import { handleError } from '@/store/state.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import ModalWrapper from './modal/ModalWrapper.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get_available_skins } from '@/helpers/skins'
|
||||
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
|
||||
defineProps({
|
||||
mode: {
|
||||
@@ -124,72 +212,258 @@ defineProps({
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const accounts = ref({})
|
||||
const microsoftLoginDisabled = ref(false)
|
||||
const elybyLoginDisabled = ref(false)
|
||||
const defaultUser = ref()
|
||||
const loginOfflineModal = ref(null)
|
||||
const loginErrorModal = ref(null)
|
||||
const unexpectedErrorModal = ref(null)
|
||||
const playerName = ref('')
|
||||
|
||||
async function tryOfflineLogin() { // Patched
|
||||
loginOfflineModal.value.show()
|
||||
// [AR] • Feature
|
||||
const clientToken = "astralrinth"
|
||||
const addOfflineModal = ref(null)
|
||||
const addElybyModal = ref(null)
|
||||
const requestElybyTwoFactorCodeModal = ref(null)
|
||||
const authenticationElybyErrorModal = ref(null)
|
||||
const inputElybyErrorModal = ref(null)
|
||||
const inputErrorModal = ref(null)
|
||||
const exceptionErrorModal = ref(null)
|
||||
const offlinePlayerName = ref('')
|
||||
const elybyLogin = ref('')
|
||||
const elybyPassword = ref('')
|
||||
const elybyTwoFactorCode = ref('')
|
||||
const minOfflinePlayerNameLength = 2
|
||||
const maxOfflinePlayerNameLength = 20
|
||||
|
||||
// [AR] • Feature
|
||||
function getAccountType(account) {
|
||||
switch (account.account_type) {
|
||||
case 'microsoft':
|
||||
return License
|
||||
case 'pirate':
|
||||
return Offline
|
||||
case 'elyby':
|
||||
return Elyby
|
||||
}
|
||||
}
|
||||
|
||||
async function offlineLoginFinally() { // Patched
|
||||
let name = playerName.value
|
||||
if (name.length > 1 && name.length < 20 && name !== '') {
|
||||
const loggedIn = await offline_login(name).catch(handleError)
|
||||
loginOfflineModal.value.hide()
|
||||
if (loggedIn) {
|
||||
await setAccount(loggedIn)
|
||||
// [AR] • Feature
|
||||
function showOfflineLoginModal() {
|
||||
addOfflineModal.value?.show()
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
function showElybyLoginModal() {
|
||||
addElybyModal.value?.show()
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
function retryAddOfflineProfile() {
|
||||
inputErrorModal.value?.hide()
|
||||
clearOfflineFields()
|
||||
showOfflineLoginModal()
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
function retryAddElybyProfile() {
|
||||
authenticationElybyErrorModal.value?.hide()
|
||||
inputElybyErrorModal.value?.hide()
|
||||
clearElybyFields()
|
||||
showElybyLoginModal()
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
function clearElybyFields() {
|
||||
elybyLogin.value = ''
|
||||
elybyPassword.value = ''
|
||||
elybyTwoFactorCode.value = ''
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
function clearOfflineFields() {
|
||||
offlinePlayerName.value = ''
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
async function addOfflineProfile() {
|
||||
const name = offlinePlayerName.value.trim()
|
||||
const isValidName = name.length >= minOfflinePlayerNameLength && name.length <= maxOfflinePlayerNameLength
|
||||
|
||||
if (!isValidName) {
|
||||
addOfflineModal.value?.hide()
|
||||
inputErrorModal.value?.show()
|
||||
clearOfflineFields()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await offline_login(name)
|
||||
|
||||
addOfflineModal.value?.hide()
|
||||
|
||||
if (result) {
|
||||
await setAccount(result)
|
||||
await refreshValues()
|
||||
} else {
|
||||
unexpectedErrorModal.value.show()
|
||||
exceptionErrorModal.value?.show()
|
||||
}
|
||||
playerName.value = ''
|
||||
} else {
|
||||
playerName.value = ''
|
||||
loginOfflineModal.value.hide()
|
||||
loginErrorModal.value.show()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
exceptionErrorModal.value?.show()
|
||||
} finally {
|
||||
clearOfflineFields()
|
||||
}
|
||||
}
|
||||
|
||||
function retryOfflineLogin() { // Patched
|
||||
loginErrorModal.value.hide()
|
||||
tryOfflineLogin()
|
||||
}
|
||||
// [AR] • Feature
|
||||
async function addElybyProfile() {
|
||||
if (!elybyLogin.value || !elybyPassword.value) {
|
||||
addElybyModal.value?.hide()
|
||||
inputElybyErrorModal.value?.show()
|
||||
clearElybyFields()
|
||||
return
|
||||
}
|
||||
elybyLoginDisabled.value = true
|
||||
|
||||
function getAccountType(account) { // Patched
|
||||
if (account.access_token != "null" && account.access_token != null && account.access_token != "") {
|
||||
return License
|
||||
} else {
|
||||
return Offline
|
||||
const login = elybyLogin.value.trim()
|
||||
let password = elybyPassword.value.trim()
|
||||
const twoFactorCode = elybyTwoFactorCode.value.trim()
|
||||
if (password && twoFactorCode) {
|
||||
password = `${password}:${twoFactorCode}`
|
||||
}
|
||||
|
||||
try {
|
||||
const raw_result = await elyby_auth_authenticate(
|
||||
login,
|
||||
password,
|
||||
clientToken
|
||||
)
|
||||
|
||||
const json_data = JSON.parse(raw_result)
|
||||
|
||||
console.log(json_data?.error)
|
||||
console.log(json_data?.errorMessage)
|
||||
|
||||
if (!json_data.accessToken) {
|
||||
if (
|
||||
json_data.error === 'ForbiddenOperationException' &&
|
||||
json_data.errorMessage?.includes('two factor')
|
||||
) {
|
||||
requestElybyTwoFactorCodeModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
addElybyModal.value?.hide()
|
||||
requestElybyTwoFactorCodeModal.value?.hide()
|
||||
authenticationElybyErrorModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
const accessToken = json_data.accessToken
|
||||
const selectedProfileId = convertRawStringToUUIDv4(json_data.selectedProfile.id)
|
||||
const selectedProfileName = json_data.selectedProfile.name
|
||||
|
||||
const result = await elyby_login(selectedProfileId, selectedProfileName, accessToken)
|
||||
|
||||
addElybyModal.value?.hide()
|
||||
requestElybyTwoFactorCodeModal.value?.hide()
|
||||
|
||||
clearElybyFields()
|
||||
|
||||
await setAccount(result)
|
||||
await refreshValues()
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
exceptionErrorModal.value?.show()
|
||||
} finally {
|
||||
elybyLoginDisabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
function convertRawStringToUUIDv4(rawId) {
|
||||
if (rawId.length !== 32) {
|
||||
console.warn('Invalid UUID string:', rawId)
|
||||
return rawId
|
||||
}
|
||||
return `${rawId.slice(0, 8)}-${rawId.slice(8, 12)}-${rawId.slice(12, 16)}-${rawId.slice(16, 20)}-${rawId.slice(20)}`
|
||||
}
|
||||
|
||||
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) {
|
||||
microsoftLoginDisabled.value = value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refreshValues,
|
||||
setLoginDisabled,
|
||||
loginDisabled: microsoftLoginDisabled,
|
||||
})
|
||||
await refreshValues()
|
||||
|
||||
const displayAccounts = computed(() =>
|
||||
accounts.value.filter((account) => defaultUser.value !== account.id),
|
||||
accounts.value.filter((account) => defaultUser.value !== account.profile.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.id === defaultUser.value),
|
||||
accounts.value.find((account) => account.profile.id === defaultUser.value),
|
||||
)
|
||||
|
||||
async function setAccount(account) {
|
||||
defaultUser.value = account.id
|
||||
await set_default_user(account.id).catch(handleError)
|
||||
defaultUser.value = account.profile.id
|
||||
await set_default_user(account.profile.id).catch(handleError)
|
||||
emit('change')
|
||||
}
|
||||
|
||||
async function login() {
|
||||
microsoftLoginDisabled.value = true
|
||||
const loggedIn = await login_flow().catch(handleSevereError)
|
||||
|
||||
if (loggedIn) {
|
||||
@@ -198,6 +472,7 @@ async function login() {
|
||||
}
|
||||
|
||||
trackEvent('AccountLogIn')
|
||||
microsoftLoginDisabled.value = false
|
||||
}
|
||||
|
||||
const logout = async (id) => {
|
||||
|
||||
@@ -18,11 +18,16 @@ import { cancel_directory_change } from '@/helpers/settings.ts'
|
||||
import { install } from '@/helpers/profile.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { applyMigrationFix } from '@/helpers/utils.js'
|
||||
import { restartApp } from '@/helpers/utils.js'
|
||||
|
||||
const errorModal = ref()
|
||||
const error = ref()
|
||||
const closable = ref(true)
|
||||
const errorCollapsed = ref(false)
|
||||
const language = ref('en')
|
||||
const migrationFixSuccess = ref(null) // null | true | false
|
||||
const migrationFixCallbackModel = ref()
|
||||
|
||||
const title = ref('An error occurred')
|
||||
const errorType = ref('unknown')
|
||||
@@ -92,7 +97,7 @@ async function loginMinecraft() {
|
||||
const loggedIn = await login_flow()
|
||||
|
||||
if (loggedIn) {
|
||||
await set_default_user(loggedIn.id).catch(handleError)
|
||||
await set_default_user(loggedIn.profile.id).catch(handleError)
|
||||
}
|
||||
|
||||
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
||||
@@ -148,6 +153,30 @@ async function copyToClipboard(text) {
|
||||
copied.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function toggleLanguage() {
|
||||
language.value = language.value === 'en' ? 'ru' : 'en'
|
||||
}
|
||||
|
||||
async function onApplyMigrationFix(eol) {
|
||||
console.log(`[AR] • Attempting to apply migration ${eol.toUpperCase()} fix`)
|
||||
try {
|
||||
const result = await applyMigrationFix(eol)
|
||||
migrationFixSuccess.value = result === true
|
||||
console.log(`[AR] • Successfully applied migration ${eol.toUpperCase()} fix`, result)
|
||||
} catch (err) {
|
||||
console.error(`[AR] • Failed to apply migration fix:`, err)
|
||||
migrationFixSuccess.value = false
|
||||
} finally {
|
||||
migrationFixCallbackModel.value?.show?.()
|
||||
if (migrationFixSuccess.value === true) {
|
||||
setTimeout(async () => {
|
||||
await restartApp()
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -219,8 +248,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 dirctory 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 directory you
|
||||
selected. Please free up some space and try again or cancel the directory change.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -298,10 +327,20 @@ async function copyToClipboard(text) {
|
||||
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
|
||||
<template v-else> <CopyIcon /> Copy debug info </template>
|
||||
</button>
|
||||
<ButtonStyled class="neon-button neon">
|
||||
<a href="https://me.astralium.su/get/ar/help" target="_blank" rel="noopener noreferrer">
|
||||
Get AstralRinth support
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled class="neon-button neon" >
|
||||
<a href="https://me.astralium.su/get/ar" target="_blank" rel="noopener noreferrer">
|
||||
Checkout latest releases
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<template v-if="hasDebugInfo">
|
||||
<div class="bg-button-bg rounded-xl mt-2 overflow-clip">
|
||||
<div class="bg-button-bg rounded-xl mt-2 overflow-hidden">
|
||||
<button
|
||||
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
|
||||
@click="errorCollapsed = !errorCollapsed"
|
||||
@@ -313,12 +352,123 @@ async function copyToClipboard(text) {
|
||||
/>
|
||||
</button>
|
||||
<Collapsible :collapsed="errorCollapsed">
|
||||
<pre class="m-0 px-4 py-3 bg-bg rounded-none">{{ debugInfo }}</pre>
|
||||
<pre
|
||||
class="m-0 px-4 py-3 bg-bg rounded-none whitespace-pre-wrap break-words overflow-x-auto max-w-full"
|
||||
>{{ debugInfo }}</pre>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<template v-if="errorType === 'state_init'">
|
||||
<div class="notice">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 v-if="language === 'en'" class="notice__title">⚠️ Migration Issue • Important Notice ⚠️</h3>
|
||||
<h3 v-if="language === 'ru'" class="notice__title">⚠️ Проблема миграции • Важное уведомление ⚠️</h3>
|
||||
<ButtonStyled>
|
||||
<button @click="toggleLanguage">
|
||||
{{ language === 'en' ? '📖 Русский' : '📖 English' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p v-if="language === 'en'" class="notice__text">
|
||||
We're experiencing an issue with our database migration system due to differences in how different operating systems handle line endings. This might cause problems with our app's functionality.
|
||||
</p>
|
||||
<p v-if="language === 'en'" class="notice__text">
|
||||
<strong>What's happening?</strong> When we build our app, we use a system that checks the integrity of our database migrations. However, this system can get confused when it encounters different line endings (like CRLF vs LF) used by different operating systems. This can lead to errors and make our app unusable.
|
||||
</p>
|
||||
<p v-if="language === 'en'" class="notice__text">
|
||||
<strong>Why is this happening?</strong> This issue is caused by a combination of factors, including different operating systems handling line endings differently, Git's line ending conversion settings, and our app's build process.
|
||||
</p>
|
||||
<p v-if="language === 'en'" class="notice__text">
|
||||
<strong>What are we doing about it?</strong> We're working to resolve this issue and ensure that our app works smoothly for all users. In the meantime, we apologize for any inconvenience this might cause and appreciate your patience and understanding.
|
||||
</p>
|
||||
<p v-if="language === 'ru'" class="notice__text">
|
||||
Мы сталкиваемся с проблемой в нашей системе миграции базы данных из-за различий в том, как разные операционные системы обрабатывают окончания строк. Это может вызвать проблемы с функциональностью нашего приложения.
|
||||
</p>
|
||||
<p v-if="language === 'ru'" class="notice__text">
|
||||
<strong>Что происходит?</strong> Когда мы строим наше приложение, мы используем систему, которая проверяет целостность наших миграций базы данных. Однако эта система может сбиваться, когда сталкивается с различными окончаниями строк (например, CRLF против LF), используемыми разными операционными системами. Это может привести к ошибкам и сделать наше приложение неработоспособным.
|
||||
</p>
|
||||
<p v-if="language === 'ru'" class="notice__text">
|
||||
<strong>Почему это происходит?</strong> Эта проблема вызвана сочетанием факторов, включая различную обработку окончаний строк разными операционными системами, настройки преобразования окончаний строк в Git и процесс сборки нашего приложения.
|
||||
</p>
|
||||
<p v-if="language === 'ru'" class="notice__text">
|
||||
<strong>Что мы с этим делаем?</strong> Мы работаем над решением этой проблемы и обеспечением бесперебойной работы нашего приложения для всех пользователей. В это время мы извиняемся за возможные неудобства и благодарим вас за терпение и понимание.
|
||||
</p>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-contrast">
|
||||
<template v-if="language === 'en'">Possible fix in real time:</template>
|
||||
<template v-if="language === 'ru'">Возможное исправление в реальном времени:</template>
|
||||
</h2>
|
||||
<div class="flex justify-between">
|
||||
<ol class="flex flex-col gap-3">
|
||||
<li>
|
||||
<ButtonStyled class="neon-button neon">
|
||||
<button
|
||||
:title="language === 'en'
|
||||
? 'Convert all line endings in migration files to LF (Unix-style: \\n)'
|
||||
: 'Преобразовать все окончания строк в файлах миграций в LF (Unix-стиль: \\n)'"
|
||||
aria-label="LF"
|
||||
@click="onApplyMigrationFix('lf')"
|
||||
>
|
||||
{{ language === 'en' ? 'Apply LF Migration Fix' : 'Применить исправление миграции LF' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
<li>
|
||||
<ButtonStyled class="neon-button neon">
|
||||
<button
|
||||
:title="language === 'en'
|
||||
? 'Convert all line endings in migration files to CRLF (Windows-style: \\r\\n)'
|
||||
: 'Преобразовать все окончания строк в файлах миграций в CRLF (Windows-стиль: \\r\\n)'"
|
||||
aria-label="CRLF"
|
||||
@click="onApplyMigrationFix('crlf')"
|
||||
>
|
||||
{{ language === 'en' ? 'Apply CRLF Migration Fix' : 'Применить исправление миграции CRLF' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper
|
||||
ref="migrationFixCallbackModel"
|
||||
:header="language === 'en'
|
||||
? '💡 Migration fix report'
|
||||
: '💡 Отчет об исправлении миграции'"
|
||||
:closable="closable">
|
||||
<div class="modal-body">
|
||||
<h2 class="text-lg font-bold text-contrast space-y-2">
|
||||
<template v-if="migrationFixSuccess === true">
|
||||
<p class="flex items-center gap-2 neon-text">
|
||||
✅
|
||||
{{ language === 'en'
|
||||
? 'The migration fix has been applied successfully. Please restart the launcher and try to log in to the game :)'
|
||||
: 'Исправление миграции успешно применено. Пожалуйста, перезапустите лаунчер и попробуйте снова авторизоваться в игре :)' }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm neon-text">
|
||||
{{ language === 'en'
|
||||
? 'If the problem persists, please try the other fix.'
|
||||
: 'Если проблема сохраняется, пожалуйста, попробуйте другой способ.' }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="migrationFixSuccess === false">
|
||||
<p class="flex items-center gap-2 neon-text">
|
||||
❌
|
||||
{{ language === 'en'
|
||||
? 'The migration fix failed or had no effect.'
|
||||
: 'Исправление миграции не было успешно применено или не имело эффекта.' }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm neon-text">
|
||||
{{ language === 'en'
|
||||
? 'If the problem persists, please try the other fix.'
|
||||
: 'Если проблема сохраняется, пожалуйста, попробуйте другой способ.' }}
|
||||
</p>
|
||||
</template>
|
||||
</h2>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@@ -333,6 +483,9 @@ async function copyToClipboard(text) {
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '../../../../../packages/assets/styles/neon-button.scss';
|
||||
@import '../../../../../packages/assets/styles/neon-text.scss';
|
||||
|
||||
.cta-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -19,7 +19,6 @@ 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()
|
||||
|
||||
@@ -173,7 +172,10 @@ onUnmounted(() => unlisten())
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
|
||||
<TimerIcon />
|
||||
<span class="text-sm">
|
||||
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
|
||||
<template v-if="instance.last_played">
|
||||
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,8 +239,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">
|
||||
{{ formatCategory(instance.loader) }} {{ instance.game_version }}
|
||||
<span class="text-sm capitalize">
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,6 @@ async function testJava() {
|
||||
testingJava.value = true
|
||||
testingJavaSuccess.value = await test_jre(
|
||||
props.modelValue ? props.modelValue.path : '',
|
||||
1,
|
||||
props.version,
|
||||
)
|
||||
testingJava.value = false
|
||||
@@ -127,7 +126,7 @@ async function handleJavaFileInput() {
|
||||
const filePath = await open()
|
||||
|
||||
if (filePath) {
|
||||
let result = await get_jre(filePath.path ?? filePath)
|
||||
let result = await get_jre(filePath.path ?? filePath).catch(handleError)
|
||||
if (!result) {
|
||||
result = {
|
||||
path: filePath.path ?? filePath,
|
||||
|
||||
@@ -30,7 +30,7 @@ const getInstances = async () => {
|
||||
|
||||
return dateB - dateA
|
||||
})
|
||||
.slice(0, 4)
|
||||
.slice(0, 3)
|
||||
}
|
||||
|
||||
await getInstances()
|
||||
|
||||
@@ -36,44 +36,6 @@
|
||||
<span class="circle stopped" />
|
||||
<span class="running-text"> No instances running </span>
|
||||
</div>
|
||||
<div v-if="updateState">
|
||||
<a>
|
||||
<Button class="download" :disabled="installState" @click="confirmUpdating(), getRemote(false, false)">
|
||||
<DownloadIcon />
|
||||
{{
|
||||
installState
|
||||
? "Downloading new update..."
|
||||
: "Download new update"
|
||||
}}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
<ModalWrapper ref="confirmUpdate" :has-to-type="false" header="Request to update the AstralRinth launcher">
|
||||
<div class="modal-body">
|
||||
<div class="markdown-body">
|
||||
<p>The new version of the AstralRinth launcher is available.</p>
|
||||
<p>Your version is outdated. We recommend that you update to the latest version.</p>
|
||||
<p><strong>⚠️ Warning ⚠️</strong></p>
|
||||
<p>
|
||||
Before updating, make sure that you have saved all running instances and made a backup copy of the instances
|
||||
that are valuable to you. Remember that the authors of the product are not responsible for the breakdown of
|
||||
your files, so you should always make copies of them and keep them in a safe place.
|
||||
</p>
|
||||
</div>
|
||||
<span>Source • Git Astralium</span>
|
||||
<span>Version on remote server • <p id="releaseData" class="cosmic inline-fix"></p></span>
|
||||
<span>Version on local device •
|
||||
<p class="cosmic inline-fix">v{{ version }}</p>
|
||||
</span>
|
||||
<div class="button-group push-right">
|
||||
<Button class="download-modal" @click="confirmUpdate.hide()">
|
||||
Decline</Button>
|
||||
<Button class="download-modal" @click="approveUpdate()">
|
||||
Accept
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</div>
|
||||
<transition name="download">
|
||||
<Card v-if="showCard === true && currentLoadingBars.length > 0" ref="card" class="info-card">
|
||||
@@ -122,25 +84,6 @@ import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { get_many } from '@/helpers/profile.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
|
||||
const version = await getVersion()
|
||||
|
||||
import { installState, getRemote, updateState } from '@/helpers/update.js'
|
||||
import ModalWrapper from './modal/ModalWrapper.vue'
|
||||
|
||||
const confirmUpdate = ref(null)
|
||||
|
||||
const confirmUpdating = async () => {
|
||||
confirmUpdate.value.show()
|
||||
}
|
||||
|
||||
const approveUpdate = async () => {
|
||||
confirmUpdate.value.hide()
|
||||
await getRemote(true, true)
|
||||
}
|
||||
|
||||
await getRemote(true, false)
|
||||
|
||||
const router = useRouter()
|
||||
const card = ref(null)
|
||||
@@ -298,101 +241,6 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.inline-fix {
|
||||
display: inline-flex;
|
||||
margin-top: -2rem;
|
||||
margin-bottom: -2rem;
|
||||
//margin-left: 0.3rem;
|
||||
}
|
||||
|
||||
.cosmic {
|
||||
color: #3e8cde;
|
||||
text-decoration: none;
|
||||
text-shadow:
|
||||
0 0 4px rgba(79, 173, 255, 0.5),
|
||||
0 0 8px rgba(14, 98, 204, 0.5),
|
||||
0 0 12px rgba(122, 31, 199, 0.5);
|
||||
transition: color 0.35s ease;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
:deep(table) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
:deep(hr),
|
||||
:deep(h1),
|
||||
:deep(h2) {
|
||||
max-width: max(60rem, 90%);
|
||||
}
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: var(--gap-lg);
|
||||
text-align: left;
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
.download {
|
||||
color: #3e8cde;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-button-bg);
|
||||
// padding: var(--gap-sm) var(--gap-lg);
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
text-decoration: none;
|
||||
text-shadow:
|
||||
0 0 4px rgba(79, 173, 255, 0.5),
|
||||
0 0 8px rgba(14, 98, 204, 0.5),
|
||||
0 0 12px rgba(122, 31, 199, 0.5);
|
||||
transition: color 0.35s ease;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.download:hover,
|
||||
.download:focus,
|
||||
.download:active {
|
||||
color: #10fae5;
|
||||
text-shadow: #26065e;
|
||||
}
|
||||
|
||||
.download-modal {
|
||||
color: #3e8cde;
|
||||
padding: var(--gap-sm) var(--gap-lg);
|
||||
text-decoration: none;
|
||||
text-shadow:
|
||||
0 0 4px rgba(79, 173, 255, 0.5),
|
||||
0 0 8px rgba(14, 98, 204, 0.5),
|
||||
0 0 12px rgba(122, 31, 199, 0.5);
|
||||
transition: color 0.35s ease;
|
||||
}
|
||||
|
||||
.download-modal:hover,
|
||||
.download-modal:focus,
|
||||
.download-modal:active {
|
||||
color: #10fae5;
|
||||
text-shadow: #26065e;
|
||||
}
|
||||
|
||||
.action-groups {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type Version,
|
||||
} from '@modrinth/utils'
|
||||
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get_project, get_version_many } from '@/helpers/cache'
|
||||
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -35,6 +36,11 @@ import type {
|
||||
Manifest,
|
||||
} from '../../../helpers/types'
|
||||
|
||||
import { initAuthlibPatching } from '@/helpers/utils.js'
|
||||
const authLibPatchingModal = ref(null)
|
||||
const isAuthLibPatchedSuccess = ref(false)
|
||||
const isAuthLibPatching = ref(false)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const repairConfirmModal = ref()
|
||||
@@ -447,9 +453,43 @@ const messages = defineMessages({
|
||||
defaultMessage: 'reinstall',
|
||||
},
|
||||
})
|
||||
|
||||
async function handleInitAuthLibPatching(ismojang: boolean) {
|
||||
isAuthLibPatching.value = true
|
||||
let state = false
|
||||
let instance_path = props.instance.loader_version != null ? props.instance.game_version + "-" + props.instance.loader_version : props.instance.game_version
|
||||
try {
|
||||
state = await initAuthlibPatching(instance_path, ismojang)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
isAuthLibPatching.value = false
|
||||
isAuthLibPatchedSuccess.value = state
|
||||
authLibPatchingModal.value.show()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper
|
||||
ref="authLibPatchingModal"
|
||||
:header="'AuthLib installation report'"
|
||||
:closable="true"
|
||||
@close="authLibPatchingModal.hide()"
|
||||
>
|
||||
<div class="modal-body">
|
||||
<h2 class="text-lg font-bold text-contrast space-y-2">
|
||||
<p class="flex items-center gap-2 neon-text">
|
||||
<span v-if="isAuthLibPatchedSuccess" class="neon-text">
|
||||
AuthLib installation completed successfully! Now you can log in and play!
|
||||
</span>
|
||||
<span v-else class="neon-text">
|
||||
Failed to install AuthLib. It's possible that no compatible AuthLib version was found for the selected game and/or mod loader version.
|
||||
There may also be a problem with accessing resources behind CloudFlare.
|
||||
</span>
|
||||
</p>
|
||||
</h2>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ConfirmModalWrapper
|
||||
ref="repairConfirmModal"
|
||||
:title="formatMessage(messages.repairConfirmTitle)"
|
||||
@@ -720,6 +760,24 @@ const messages = defineMessages({
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<h2 class="m-0 mt-4 text-lg font-extrabold text-contrast block">
|
||||
<div v-if="isAuthLibPatching" class="w-6 h-6 cursor-pointer hover:brightness-75 neon-icon pulse">
|
||||
<SpinnerIcon class="size-4 animate-spin" />
|
||||
</div>
|
||||
Auth system (Skins) <span class="text-sm font-bold px-2 bg-brand-highlight text-brand rounded-full">Beta</span>
|
||||
</h2>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<ButtonStyled class="neon-button neon">
|
||||
<button :disabled="isAuthLibPatching" @click="handleInitAuthLibPatching(true)">
|
||||
Install Microsoft
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled class="neon-button neon">
|
||||
<button :disabled="isAuthLibPatching" @click="handleInitAuthLibPatching(false) ">
|
||||
Install Ely.By
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="instance.linked_data && instance.linked_data.locked">
|
||||
@@ -787,3 +845,9 @@ const messages = defineMessages({
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../../../../../../packages/assets/styles/neon-button.scss';
|
||||
@import '../../../../../../packages/assets/styles/neon-text.scss';
|
||||
@import '../../../../../../packages/assets/styles/neon-icon.scss';
|
||||
</style>
|
||||
@@ -6,9 +6,9 @@ import { edit, get_optimal_jre_key } from '@/helpers/profile'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||
import { get_max_memory } from '@/helpers/jre'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -34,7 +34,7 @@ const envVars = ref(
|
||||
|
||||
const overrideMemorySettings = ref(!!props.instance.memory)
|
||||
const memory = ref(props.instance.memory ?? globalSettings.memory)
|
||||
const maxMemory = Math.floor((await get_max_memory().catch(handleError)) / 1024)
|
||||
const { maxMemory, snapPoints } = await useMemorySlider()
|
||||
|
||||
const editProfileObject = computed(() => {
|
||||
const editProfile: {
|
||||
@@ -156,6 +156,8 @@ const messages = defineMessages({
|
||||
:min="512"
|
||||
:max="maxMemory"
|
||||
:step="64"
|
||||
:snap-points="snapPoints"
|
||||
:snap-range="512"
|
||||
unit="MB"
|
||||
/>
|
||||
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
ShieldIcon,
|
||||
SettingsIcon,
|
||||
GaugeIcon,
|
||||
PaintBrushIcon,
|
||||
PaintbrushIcon,
|
||||
GameIcon,
|
||||
CoffeeIcon,
|
||||
DownloadIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { TabbedModal } from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
@@ -23,6 +25,23 @@ import { useTheming } from '@/store/state'
|
||||
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
// [AR] Imports
|
||||
import { installState, getRemote, updateState } from '@/helpers/update.js'
|
||||
|
||||
const updateModalView = ref(null)
|
||||
const updateRequestFailView = ref(null)
|
||||
|
||||
const initUpdateModal = async () => {
|
||||
updateModalView.value.show()
|
||||
}
|
||||
|
||||
const initDownload = async () => {
|
||||
updateModalView.value.hide()
|
||||
const result = await getRemote(true);
|
||||
if (!result) {
|
||||
updateRequestFailView.value.show()
|
||||
}
|
||||
}
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -41,7 +60,7 @@ const tabs = [
|
||||
id: 'app.settings.tabs.appearance',
|
||||
defaultMessage: 'Appearance',
|
||||
}),
|
||||
icon: PaintBrushIcon,
|
||||
icon: PaintbrushIcon,
|
||||
content: AppearanceSettings,
|
||||
},
|
||||
{
|
||||
@@ -138,11 +157,10 @@ function devModeCount() {
|
||||
{{ formatMessage(developerModeEnabled) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
<button
|
||||
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
|
||||
:class="{ 'text-brand': themeStore.devMode, 'text-secondary': !themeStore.devMode }"
|
||||
@click="devModeCount"
|
||||
>
|
||||
@click="devModeCount">
|
||||
<AstralRinthLogo class="w-6 h-6" />
|
||||
</button>
|
||||
<div>
|
||||
@@ -153,9 +171,80 @@ function devModeCount() {
|
||||
{{ osVersion }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="updateState" class="w-8 h-8 cursor-pointer hover:brightness-75 neon-icon pulse">
|
||||
<template v-if="installState">
|
||||
<SpinnerIcon class="size-6 animate-spin" v-tooltip.bottom="'Installing in process...'" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<DownloadIcon class="size-6" v-tooltip.bottom="'View update info'" @click="!installState && (initUpdateModal(), getRemote(false))" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</TabbedModal>
|
||||
<!-- [AR] Feature -->
|
||||
<ModalWrapper ref="updateModalView" :has-to-type="false" header="Request to update the AstralRinth launcher">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<p>The new version of the AstralRinth launcher is available.</p>
|
||||
<p>Your version is outdated. We recommend that you update to the latest version.</p>
|
||||
<p><strong>⚠️ Warning ⚠️</strong></p>
|
||||
<p>
|
||||
Before updating, make sure that you have saved all running instances and made a backup copy of the instances
|
||||
that are valuable to you. Remember that the authors of the product are not responsible for the breakdown of
|
||||
your files, so you should always make copies of them and keep them in a safe place.
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-sm text-secondary space-y-1">
|
||||
<a class="neon-text" href="https://me.astralium.su/get/ar" target="_blank"
|
||||
rel="noopener noreferrer"><strong>Source:</strong> Git Astralium</a>
|
||||
<p>
|
||||
<strong>Version on remote server:</strong>
|
||||
<span id="releaseData" class="neon-text"></span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Version on local device:</strong>
|
||||
<span class="neon-text">v{{ version }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-4 right-4 flex items-center gap-4 neon-button neon">
|
||||
<Button class="bordered" @click="updateModalView.hide()">Cancel</Button>
|
||||
<Button class="bordered" @click="initDownload()">Download file</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="updateRequestFailView" :has-to-type="false" header="Failed to request a file from the server :(">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<p><strong>Error occurred</strong></p>
|
||||
<p>Unfortunately, the program was unable to download the file from our servers.</p>
|
||||
<p>
|
||||
Please try downloading it yourself from
|
||||
<a class="neon-text" href="https://me.astralium.su/get/ar" target="_blank" rel="noopener noreferrer">Git
|
||||
Astralium</a>
|
||||
if there are any updates available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-secondary">
|
||||
<p>
|
||||
<strong>Local AstralRinth:</strong>
|
||||
<span class="neon-text">v{{ version }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-4 right-4 flex items-center gap-4 neon-button neon">
|
||||
<Button class="bordered" @click="updateRequestFailView.hide()">Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../../../../../../packages/assets/styles/neon-icon.scss';
|
||||
@import '../../../../../../packages/assets/styles/neon-button.scss';
|
||||
@import '../../../../../../packages/assets/styles/neon-text.scss';
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useTemplateRef } 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.js'
|
||||
@@ -26,15 +26,16 @@ const props = defineProps({
|
||||
// default: true,
|
||||
// },
|
||||
})
|
||||
const modal = ref(null)
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
defineExpose({
|
||||
show: () => {
|
||||
modal.value.show()
|
||||
show: (e: MouseEvent) => {
|
||||
// hide_ads_window()
|
||||
modal.value?.show(e)
|
||||
},
|
||||
hide: () => {
|
||||
onModalHide()
|
||||
modal.value.hide()
|
||||
modal.value?.hide()
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -56,9 +56,17 @@ 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,9 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { ref, watch } from 'vue'
|
||||
import { get_max_memory } from '@/helpers/jre'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { Slider, Toggle } from '@modrinth/ui'
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
|
||||
const fetchSettings = await get()
|
||||
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
|
||||
@@ -11,7 +10,7 @@ fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).jo
|
||||
|
||||
const settings = ref(fetchSettings)
|
||||
|
||||
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
|
||||
const { maxMemory, snapPoints } = await useMemorySlider()
|
||||
|
||||
watch(
|
||||
settings,
|
||||
@@ -107,6 +106,8 @@ watch(
|
||||
:min="512"
|
||||
:max="maxMemory"
|
||||
:step="64"
|
||||
:snap-points="snapPoints"
|
||||
:snap-range="512"
|
||||
unit="MB"
|
||||
/>
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ watch(
|
||||
option, you opt out and ads will no longer be shown based on your interests.
|
||||
</p>
|
||||
</div>
|
||||
<!-- AstralRinth disabled element by default -->
|
||||
<!-- [AR] Patch. Disabled element by default -->
|
||||
<Toggle id="personalized-ads" v-model="settings.personalized_ads" :disabled="!settings.personalized_ads" />
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ watch(
|
||||
longer be collected.
|
||||
</p>
|
||||
</div>
|
||||
<!-- AstralRinth disabled element by default -->
|
||||
<!-- [AR] Patch. Disabled element by default -->
|
||||
<Toggle id="opt-out-analytics" v-model="settings.telemetry" :disabled="!settings.telemetry" />
|
||||
</div>
|
||||
|
||||
|
||||
413
apps/app-frontend/src/components/ui/skin/EditSkinModal.vue
Normal file
413
apps/app-frontend/src/components/ui/skin/EditSkinModal.vue
Normal file
@@ -0,0 +1,413 @@
|
||||
<template>
|
||||
<UploadSkinModal ref="uploadModal" />
|
||||
<ModalWrapper ref="modal" @on-hide="resetState">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">
|
||||
{{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
|
||||
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
|
||||
<SkinPreviewRenderer
|
||||
:variant="variant"
|
||||
:texture-src="previewSkin || ''"
|
||||
:cape-src="selectedCapeTexture"
|
||||
:scale="1.4"
|
||||
:fov="50"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Texture</h2>
|
||||
<Button @click="openUploadSkinModal"> <UploadIcon /> Replace texture </Button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Arm style</h2>
|
||||
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
|
||||
<template #default="{ item }">
|
||||
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
|
||||
</template>
|
||||
</RadioButtons>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Cape</h2>
|
||||
<div class="flex gap-2">
|
||||
<CapeButton
|
||||
v-if="defaultCape"
|
||||
:id="defaultCape.id"
|
||||
:texture="defaultCape.texture"
|
||||
:name="undefined"
|
||||
:selected="!selectedCape"
|
||||
faded
|
||||
@select="selectCape(undefined)"
|
||||
>
|
||||
<span>Use default cape</span>
|
||||
</CapeButton>
|
||||
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
|
||||
<span>Use default cape</span>
|
||||
</CapeLikeTextButton>
|
||||
|
||||
<CapeButton
|
||||
v-for="cape in visibleCapeList"
|
||||
:id="cape.id"
|
||||
:key="cape.id"
|
||||
:texture="cape.texture"
|
||||
:name="cape.name || 'Cape'"
|
||||
:selected="selectedCape?.id === cape.id"
|
||||
@select="selectCape(cape)"
|
||||
/>
|
||||
|
||||
<CapeLikeTextButton
|
||||
v-if="(capes?.length ?? 0) > 2"
|
||||
tooltip="View more capes"
|
||||
@mouseup="openSelectCapeModal"
|
||||
>
|
||||
<template #icon><ChevronRightIcon /></template>
|
||||
<span>More</span>
|
||||
</CapeLikeTextButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-12">
|
||||
<ButtonStyled color="brand" :disabled="disableSave || isSaving">
|
||||
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
|
||||
<SpinnerIcon v-if="isSaving" class="animate-spin" />
|
||||
<CheckIcon v-else-if="mode === 'new'" />
|
||||
<SaveIcon v-else />
|
||||
{{ mode === 'new' ? 'Add skin' : 'Save skin' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<Button :disabled="isSaving" @click="hide"><XIcon />Cancel</Button>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
|
||||
<SelectCapeModal
|
||||
ref="selectCapeModal"
|
||||
:capes="capes || []"
|
||||
@select="handleCapeSelected"
|
||||
@cancel="handleCapeCancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, useTemplateRef } from 'vue'
|
||||
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
|
||||
import {
|
||||
SkinPreviewRenderer,
|
||||
Button,
|
||||
RadioButtons,
|
||||
CapeButton,
|
||||
CapeLikeTextButton,
|
||||
ButtonStyled,
|
||||
} from '@modrinth/ui'
|
||||
import {
|
||||
add_and_equip_custom_skin,
|
||||
remove_custom_skin,
|
||||
unequip_skin,
|
||||
type Skin,
|
||||
type Cape,
|
||||
type SkinModel,
|
||||
get_normalized_skin_texture,
|
||||
determineModelType,
|
||||
} from '@/helpers/skins.ts'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import {
|
||||
UploadIcon,
|
||||
CheckIcon,
|
||||
SaveIcon,
|
||||
XIcon,
|
||||
ChevronRightIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
const selectCapeModal = useTemplateRef('selectCapeModal')
|
||||
const mode = ref<'new' | 'edit'>('new')
|
||||
const currentSkin = ref<Skin | null>(null)
|
||||
const shouldRestoreModal = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const uploadedTextureUrl = ref<string | null>(null)
|
||||
const previewSkin = ref<string>('')
|
||||
|
||||
const variant = ref<SkinModel>('CLASSIC')
|
||||
const selectedCape = ref<Cape | undefined>(undefined)
|
||||
const props = defineProps<{ capes?: Cape[]; defaultCape?: Cape }>()
|
||||
|
||||
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
|
||||
const visibleCapeList = ref<Cape[]>([])
|
||||
|
||||
const sortedCapes = computed(() => {
|
||||
return [...(props.capes || [])].sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase()
|
||||
const nameB = (b.name || '').toLowerCase()
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
})
|
||||
|
||||
function initVisibleCapeList() {
|
||||
if (!props.capes || props.capes.length === 0) {
|
||||
visibleCapeList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (visibleCapeList.value.length === 0) {
|
||||
if (selectedCape.value) {
|
||||
const otherCape = getSortedCapeExcluding(selectedCape.value.id)
|
||||
visibleCapeList.value = otherCape ? [selectedCape.value, otherCape] : [selectedCape.value]
|
||||
} else {
|
||||
visibleCapeList.value = getSortedCapes(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSortedCapes(count: number): Cape[] {
|
||||
if (!sortedCapes.value || sortedCapes.value.length === 0) return []
|
||||
return sortedCapes.value.slice(0, count)
|
||||
}
|
||||
|
||||
function getSortedCapeExcluding(excludeId: string): Cape | undefined {
|
||||
if (!sortedCapes.value || sortedCapes.value.length <= 1) return undefined
|
||||
return sortedCapes.value.find((cape) => cape.id !== excludeId)
|
||||
}
|
||||
|
||||
async function loadPreviewSkin() {
|
||||
if (uploadedTextureUrl.value) {
|
||||
previewSkin.value = uploadedTextureUrl.value
|
||||
} else if (currentSkin.value) {
|
||||
try {
|
||||
previewSkin.value = await get_normalized_skin_texture(currentSkin.value)
|
||||
} catch (error) {
|
||||
console.error('Failed to load skin texture:', error)
|
||||
previewSkin.value = '/src/assets/skins/steve.png'
|
||||
}
|
||||
} else {
|
||||
previewSkin.value = '/src/assets/skins/steve.png'
|
||||
}
|
||||
}
|
||||
|
||||
const hasEdits = computed(() => {
|
||||
if (mode.value !== 'edit') return true
|
||||
if (uploadedTextureUrl.value) return true
|
||||
if (!currentSkin.value) return false
|
||||
if (variant.value !== currentSkin.value.variant) return true
|
||||
if ((selectedCape.value?.id || null) !== (currentSkin.value.cape_id || null)) return true
|
||||
return false
|
||||
})
|
||||
|
||||
const disableSave = computed(
|
||||
() =>
|
||||
(mode.value === 'new' && !uploadedTextureUrl.value) ||
|
||||
(mode.value === 'edit' && !hasEdits.value),
|
||||
)
|
||||
|
||||
const saveTooltip = computed(() => {
|
||||
if (isSaving.value) return 'Saving...'
|
||||
if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!'
|
||||
if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!'
|
||||
return undefined
|
||||
})
|
||||
|
||||
function resetState() {
|
||||
mode.value = 'new'
|
||||
currentSkin.value = null
|
||||
uploadedTextureUrl.value = null
|
||||
previewSkin.value = ''
|
||||
variant.value = 'CLASSIC'
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
shouldRestoreModal.value = false
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
async function show(e: MouseEvent, skin?: Skin) {
|
||||
mode.value = skin ? 'edit' : 'new'
|
||||
currentSkin.value = skin ?? null
|
||||
if (skin) {
|
||||
variant.value = skin.variant
|
||||
selectedCape.value = props.capes?.find((c) => c.id === skin.cape_id)
|
||||
} else {
|
||||
variant.value = 'CLASSIC'
|
||||
selectedCape.value = undefined
|
||||
}
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
|
||||
await loadPreviewSkin()
|
||||
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
async function showNew(e: MouseEvent, skinTextureUrl: string) {
|
||||
mode.value = 'new'
|
||||
currentSkin.value = null
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
variant.value = await determineModelType(skinTextureUrl)
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
|
||||
await loadPreviewSkin()
|
||||
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
async function restoreWithNewTexture(skinTextureUrl: string) {
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
await loadPreviewSkin()
|
||||
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
setTimeout(() => resetState(), 250)
|
||||
}
|
||||
|
||||
function selectCape(cape: Cape | undefined) {
|
||||
if (cape && selectedCape.value?.id !== cape.id) {
|
||||
const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id)
|
||||
if (!isInVisibleList && visibleCapeList.value.length > 0) {
|
||||
visibleCapeList.value.splice(0, 1, cape)
|
||||
|
||||
if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) {
|
||||
const otherCape = getSortedCapeExcluding(cape.id)
|
||||
if (otherCape) {
|
||||
visibleCapeList.value.splice(1, 1, otherCape)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedCape.value = cape
|
||||
}
|
||||
|
||||
function handleCapeSelected(cape: Cape | undefined) {
|
||||
selectCape(cape)
|
||||
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCapeCancel() {
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function openSelectCapeModal(e: MouseEvent) {
|
||||
if (!selectCapeModal.value) return
|
||||
|
||||
shouldRestoreModal.value = true
|
||||
modal.value?.hide()
|
||||
|
||||
setTimeout(() => {
|
||||
selectCapeModal.value?.show(
|
||||
e,
|
||||
currentSkin.value?.texture_key,
|
||||
selectedCape.value,
|
||||
previewSkin.value,
|
||||
variant.value,
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function openUploadSkinModal(e: MouseEvent) {
|
||||
shouldRestoreModal.value = true
|
||||
modal.value?.hide()
|
||||
emit('open-upload-modal', e)
|
||||
}
|
||||
|
||||
function restoreModal() {
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
modal.value?.show(fakeEvent)
|
||||
shouldRestoreModal.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
let textureUrl: string
|
||||
|
||||
if (uploadedTextureUrl.value) {
|
||||
textureUrl = uploadedTextureUrl.value
|
||||
} else {
|
||||
textureUrl = currentSkin.value!.texture
|
||||
}
|
||||
|
||||
await unequip_skin()
|
||||
|
||||
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
|
||||
|
||||
if (mode.value === 'new') {
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
emit('saved')
|
||||
} else {
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
await remove_custom_skin(currentSkin.value!)
|
||||
emit('saved')
|
||||
}
|
||||
|
||||
hide()
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([uploadedTextureUrl, currentSkin], async () => {
|
||||
await loadPreviewSkin()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.capes,
|
||||
() => {
|
||||
initVisibleCapeList()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'saved'): void
|
||||
(event: 'deleted', skin: Skin): void
|
||||
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
|
||||
}>()
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
showNew,
|
||||
restoreWithNewTexture,
|
||||
hide,
|
||||
shouldRestoreModal,
|
||||
restoreModal,
|
||||
})
|
||||
</script>
|
||||
140
apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue
Normal file
140
apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<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>
|
||||
140
apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue
Normal file
140
apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<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,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
EyeIcon,
|
||||
@@ -42,6 +43,7 @@ const emit = defineEmits<{
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
last_played: Dayjs
|
||||
}>()
|
||||
|
||||
const loadingModpack = ref(!!props.instance.linked_data)
|
||||
@@ -147,12 +149,12 @@ onUnmounted(() => {
|
||||
: null
|
||||
"
|
||||
class="w-fit shrink-0"
|
||||
:class="{ 'cursor-help smart-clickable:allow-pointer-events': instance.last_played }"
|
||||
:class="{ 'cursor-help smart-clickable:allow-pointer-events': last_played }"
|
||||
>
|
||||
<template v-if="instance.last_played">
|
||||
<template v-if="last_played">
|
||||
{{
|
||||
formatMessage(commonMessages.playedLabel, {
|
||||
time: formatRelativeTime(instance.last_played.toISOString()),
|
||||
time: formatRelativeTime(last_played.toISOString?.()),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
|
||||
@@ -84,7 +84,7 @@ async function populateJumpBackIn() {
|
||||
|
||||
worldItems.push({
|
||||
type: 'world',
|
||||
last_played: dayjs(world.last_played),
|
||||
last_played: dayjs(world.last_played ?? 0),
|
||||
world: world,
|
||||
instance: instance,
|
||||
})
|
||||
@@ -138,13 +138,13 @@ async function populateJumpBackIn() {
|
||||
|
||||
instanceItems.push({
|
||||
type: 'instance',
|
||||
last_played: dayjs(instance.last_played),
|
||||
last_played: dayjs(instance.last_played ?? 0),
|
||||
instance: instance,
|
||||
})
|
||||
}
|
||||
|
||||
const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
|
||||
items.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played)))
|
||||
items.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0)))
|
||||
jumpBackInItems.value = items
|
||||
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO))
|
||||
.slice(0, MAX_JUMP_BACK_IN)
|
||||
@@ -291,7 +291,7 @@ onUnmounted(() => {
|
||||
"
|
||||
@stop="() => stopInstance(item.instance.path)"
|
||||
/>
|
||||
<InstanceItem v-else :instance="item.instance" />
|
||||
<InstanceItem v-else :instance="item.instance" :last_played="item.last_played" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,6 +128,14 @@ const messages = defineMessages({
|
||||
id: 'instance.worlds.game_already_open',
|
||||
defaultMessage: 'Instance is already open',
|
||||
},
|
||||
noContact: {
|
||||
id: 'instance.worlds.no_contact',
|
||||
defaultMessage: "Server couldn't be contacted",
|
||||
},
|
||||
incompatibleServer: {
|
||||
id: 'instance.worlds.incompatible_server',
|
||||
defaultMessage: 'Server is incompatible',
|
||||
},
|
||||
copyAddress: {
|
||||
id: 'instance.worlds.copy_address',
|
||||
defaultMessage: 'Copy address',
|
||||
@@ -302,39 +310,33 @@ const messages = defineMessages({
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
||||
<template v-if="world.type === 'singleplayer' || serverStatus">
|
||||
<ButtonStyled
|
||||
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
||||
color="red"
|
||||
>
|
||||
<button @click="emit('stop')">
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button
|
||||
v-tooltip="
|
||||
serverIncompatible
|
||||
? 'Server is incompatible'
|
||||
<ButtonStyled
|
||||
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
||||
color="red"
|
||||
>
|
||||
<button @click="emit('stop')">
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button
|
||||
v-tooltip="
|
||||
!serverStatus
|
||||
? formatMessage(messages.noContact)
|
||||
: serverIncompatible
|
||||
? formatMessage(messages.incompatibleServer)
|
||||
: !supportsQuickPlay
|
||||
? formatMessage(messages.noQuickPlay)
|
||||
: playingOtherWorld || locked
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: null
|
||||
"
|
||||
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||
<PlayIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<ButtonStyled v-else>
|
||||
<button class="invisible">
|
||||
<PlayIcon aria-hidden="true" />
|
||||
"
|
||||
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||
<PlayIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
21
apps/app-frontend/src/composables/useMemorySlider.js
Normal file
21
apps/app-frontend/src/composables/useMemorySlider.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { get_max_memory } from '@/helpers/jre.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
|
||||
export default async function () {
|
||||
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
|
||||
|
||||
const snapPoints = computed(() => {
|
||||
let points = []
|
||||
let memory = 2048
|
||||
|
||||
while (memory <= maxMemory.value) {
|
||||
points.push(memory)
|
||||
memory *= 2
|
||||
}
|
||||
|
||||
return points
|
||||
})
|
||||
|
||||
return { maxMemory, snapPoints }
|
||||
}
|
||||
@@ -17,6 +17,24 @@ export async function offline_login(name) {
|
||||
return await invoke('plugin:auth|offline_login', { name: name })
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
export async function elyby_login(uuid, login, accessToken) {
|
||||
return await invoke('plugin:auth|elyby_login', {
|
||||
uuid,
|
||||
login,
|
||||
accessToken
|
||||
})
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
export async function elyby_auth_authenticate(login, password, clientToken) {
|
||||
return await invoke('plugin:auth|elyby_auth_authenticate', {
|
||||
login,
|
||||
password,
|
||||
clientToken,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a user with Hydra - part 1.
|
||||
* This begins the authentication flow quasi-synchronously.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ofetch } from 'ofetch'
|
||||
import { fetch } from '@tauri-apps/plugin-http'
|
||||
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 ofetch(url, {
|
||||
return await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -36,8 +36,8 @@ export async function get_jre(path) {
|
||||
|
||||
// Tests JRE version by running 'java -version' on it.
|
||||
// Returns true if the version is valid, and matches given (after extraction)
|
||||
export async function test_jre(path, majorVersion, minorVersion) {
|
||||
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion, minorVersion })
|
||||
export async function test_jre(path, majorVersion) {
|
||||
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion })
|
||||
}
|
||||
|
||||
// Automatically installs specified java version
|
||||
|
||||
446
apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts
Normal file
446
apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import * as THREE from 'three'
|
||||
import type { Skin, Cape } from '../skins'
|
||||
import { get_normalized_skin_texture, determineModelType } from '../skins'
|
||||
import { reactive } from 'vue'
|
||||
import {
|
||||
setupSkinModel,
|
||||
disposeCaches,
|
||||
loadTexture,
|
||||
applyCapeTexture,
|
||||
createTransparentTexture,
|
||||
} from '@modrinth/utils'
|
||||
import { skinPreviewStorage } from '../storage/skin-preview-storage'
|
||||
import { headStorage } from '../storage/head-storage'
|
||||
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
|
||||
|
||||
export interface RenderResult {
|
||||
forwards: string
|
||||
backwards: string
|
||||
}
|
||||
|
||||
export interface RawRenderResult {
|
||||
forwards: Blob
|
||||
backwards: Blob
|
||||
}
|
||||
|
||||
class BatchSkinRenderer {
|
||||
private renderer: THREE.WebGLRenderer | null = null
|
||||
private scene: THREE.Scene | null = null
|
||||
private camera: THREE.PerspectiveCamera | null = null
|
||||
private currentModel: THREE.Group | null = null
|
||||
private readonly width: number
|
||||
private readonly height: number
|
||||
|
||||
constructor(width: number = 360, height: number = 504) {
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
|
||||
private initializeRenderer(): void {
|
||||
if (this.renderer) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = this.width
|
||||
canvas.height = this.height
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
canvas: canvas,
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
preserveDrawingBuffer: true,
|
||||
})
|
||||
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
this.renderer.toneMapping = THREE.NoToneMapping
|
||||
this.renderer.toneMappingExposure = 10.0
|
||||
this.renderer.setClearColor(0x000000, 0)
|
||||
this.renderer.setSize(this.width, this.height)
|
||||
|
||||
this.scene = new THREE.Scene()
|
||||
this.camera = new THREE.PerspectiveCamera(20, this.width / this.height, 0.4, 1000)
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||
directionalLight.castShadow = true
|
||||
directionalLight.position.set(2, 4, 3)
|
||||
this.scene.add(ambientLight)
|
||||
this.scene.add(directionalLight)
|
||||
}
|
||||
|
||||
public async renderSkin(
|
||||
textureUrl: string,
|
||||
modelUrl: string,
|
||||
capeUrl?: string,
|
||||
): Promise<RawRenderResult> {
|
||||
this.initializeRenderer()
|
||||
|
||||
this.clearScene()
|
||||
|
||||
await this.setupModel(modelUrl, textureUrl, capeUrl)
|
||||
|
||||
const headPart = this.currentModel!.getObjectByName('Head')
|
||||
let lookAtTarget: [number, number, number]
|
||||
|
||||
if (headPart) {
|
||||
const headPosition = new THREE.Vector3()
|
||||
headPart.getWorldPosition(headPosition)
|
||||
lookAtTarget = [headPosition.x, headPosition.y - 0.3, headPosition.z]
|
||||
} else {
|
||||
throw new Error("Failed to find 'Head' object in model.")
|
||||
}
|
||||
|
||||
const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3]
|
||||
const backCameraPos: [number, number, number] = [-1.3, 1, -2.5]
|
||||
|
||||
const forwards = await this.renderView(frontCameraPos, lookAtTarget)
|
||||
const backwards = await this.renderView(backCameraPos, lookAtTarget)
|
||||
|
||||
return { forwards, backwards }
|
||||
}
|
||||
|
||||
private async renderView(
|
||||
cameraPosition: [number, number, number],
|
||||
lookAtPosition: [number, number, number],
|
||||
): Promise<Blob> {
|
||||
if (!this.camera || !this.renderer || !this.scene) {
|
||||
throw new Error('Renderer not initialized')
|
||||
}
|
||||
|
||||
this.camera.position.set(...cameraPosition)
|
||||
this.camera.lookAt(...lookAtPosition)
|
||||
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
|
||||
const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9)
|
||||
const response = await fetch(dataUrl)
|
||||
return await response.blob()
|
||||
}
|
||||
|
||||
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
|
||||
if (!this.scene) {
|
||||
throw new Error('Renderer not initialized')
|
||||
}
|
||||
|
||||
const { model } = await setupSkinModel(modelUrl, textureUrl)
|
||||
|
||||
if (capeUrl) {
|
||||
const capeTexture = await loadTexture(capeUrl)
|
||||
applyCapeTexture(model, capeTexture)
|
||||
} else {
|
||||
const transparentTexture = createTransparentTexture()
|
||||
applyCapeTexture(model, null, transparentTexture)
|
||||
}
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(model)
|
||||
group.position.set(0, 0.3, 1.95)
|
||||
group.scale.set(0.8, 0.8, 0.8)
|
||||
|
||||
this.scene.add(group)
|
||||
this.currentModel = group
|
||||
}
|
||||
|
||||
private clearScene(): void {
|
||||
if (!this.scene) return
|
||||
|
||||
while (this.scene.children.length > 0) {
|
||||
const child = this.scene.children[0]
|
||||
this.scene.remove(child)
|
||||
|
||||
if (child instanceof THREE.Mesh) {
|
||||
if (child.geometry) child.geometry.dispose()
|
||||
if (child.material) {
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach((material) => material.dispose())
|
||||
} else {
|
||||
child.material.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||
directionalLight.castShadow = true
|
||||
directionalLight.position.set(2, 4, 3)
|
||||
this.scene.add(ambientLight)
|
||||
this.scene.add(directionalLight)
|
||||
|
||||
this.currentModel = null
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose()
|
||||
}
|
||||
disposeCaches()
|
||||
}
|
||||
}
|
||||
|
||||
function getModelUrlForVariant(variant: string): string {
|
||||
switch (variant) {
|
||||
case 'SLIM':
|
||||
return SlimPlayerModel
|
||||
case 'CLASSIC':
|
||||
case 'UNKNOWN':
|
||||
default:
|
||||
return ClassicPlayerModel
|
||||
}
|
||||
}
|
||||
|
||||
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
|
||||
export const headBlobUrlMap = reactive(new Map<string, string>())
|
||||
const DEBUG_MODE = false
|
||||
|
||||
let sharedRenderer: BatchSkinRenderer | null = null
|
||||
function getSharedRenderer(): BatchSkinRenderer {
|
||||
if (!sharedRenderer) {
|
||||
sharedRenderer = new BatchSkinRenderer()
|
||||
}
|
||||
return sharedRenderer
|
||||
}
|
||||
|
||||
export function disposeSharedRenderer(): void {
|
||||
if (sharedRenderer) {
|
||||
sharedRenderer.dispose()
|
||||
sharedRenderer = null
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
|
||||
const validKeys = new Set<string>()
|
||||
const validHeadKeys = new Set<string>()
|
||||
|
||||
for (const skin of skins) {
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
const headKey = `${skin.texture_key}-head`
|
||||
validKeys.add(key)
|
||||
validHeadKeys.add(headKey)
|
||||
}
|
||||
|
||||
try {
|
||||
await skinPreviewStorage.cleanupInvalidKeys(validKeys)
|
||||
await headStorage.cleanupInvalidKeys(validHeadKeys)
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup unused skin previews:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
const sourceCanvas = document.createElement('canvas')
|
||||
const sourceCtx = sourceCanvas.getContext('2d')
|
||||
|
||||
if (!sourceCtx) {
|
||||
throw new Error('Could not get 2D context from source canvas')
|
||||
}
|
||||
|
||||
sourceCanvas.width = img.width
|
||||
sourceCanvas.height = img.height
|
||||
|
||||
sourceCtx.drawImage(img, 0, 0)
|
||||
|
||||
const outputCanvas = document.createElement('canvas')
|
||||
const outputCtx = outputCanvas.getContext('2d')
|
||||
|
||||
if (!outputCtx) {
|
||||
throw new Error('Could not get 2D context from output canvas')
|
||||
}
|
||||
|
||||
outputCanvas.width = size
|
||||
outputCanvas.height = size
|
||||
|
||||
outputCtx.imageSmoothingEnabled = false
|
||||
|
||||
const headImageData = sourceCtx.getImageData(8, 8, 8, 8)
|
||||
|
||||
const headCanvas = document.createElement('canvas')
|
||||
const headCtx = headCanvas.getContext('2d')
|
||||
|
||||
if (!headCtx) {
|
||||
throw new Error('Could not get 2D context from head canvas')
|
||||
}
|
||||
|
||||
headCanvas.width = 8
|
||||
headCanvas.height = 8
|
||||
headCtx.putImageData(headImageData, 0, 0)
|
||||
|
||||
outputCtx.drawImage(headCanvas, 0, 0, 8, 8, 0, 0, size, size)
|
||||
|
||||
const hatImageData = sourceCtx.getImageData(40, 8, 8, 8)
|
||||
|
||||
const hatCanvas = document.createElement('canvas')
|
||||
const hatCtx = hatCanvas.getContext('2d')
|
||||
|
||||
if (!hatCtx) {
|
||||
throw new Error('Could not get 2D context from hat canvas')
|
||||
}
|
||||
|
||||
hatCanvas.width = 8
|
||||
hatCanvas.height = 8
|
||||
hatCtx.putImageData(hatImageData, 0, 0)
|
||||
|
||||
const hatPixels = hatImageData.data
|
||||
let hasHat = false
|
||||
|
||||
for (let i = 3; i < hatPixels.length; i += 4) {
|
||||
if (hatPixels[i] > 0) {
|
||||
hasHat = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (hasHat) {
|
||||
outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size)
|
||||
}
|
||||
|
||||
outputCanvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('Failed to create blob from canvas'))
|
||||
}
|
||||
},
|
||||
'image/webp',
|
||||
0.9,
|
||||
)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load skin texture image'))
|
||||
}
|
||||
|
||||
img.src = skinUrl
|
||||
})
|
||||
}
|
||||
|
||||
async function generateHeadRender(skin: Skin): Promise<string> {
|
||||
const headKey = `${skin.texture_key}-head`
|
||||
|
||||
if (headBlobUrlMap.has(headKey)) {
|
||||
if (DEBUG_MODE) {
|
||||
const url = headBlobUrlMap.get(headKey)!
|
||||
URL.revokeObjectURL(url)
|
||||
headBlobUrlMap.delete(headKey)
|
||||
} else {
|
||||
return headBlobUrlMap.get(headKey)!
|
||||
}
|
||||
}
|
||||
|
||||
const skinUrl = await get_normalized_skin_texture(skin)
|
||||
const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
|
||||
const headUrl = URL.createObjectURL(headBlob)
|
||||
|
||||
headBlobUrlMap.set(headKey, headUrl)
|
||||
|
||||
try {
|
||||
await headStorage.store(headKey, headBlob)
|
||||
} catch (error) {
|
||||
console.warn('Failed to store head render in persistent storage:', error)
|
||||
}
|
||||
|
||||
return headUrl
|
||||
}
|
||||
|
||||
export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
|
||||
return await generateHeadRender(skin)
|
||||
}
|
||||
|
||||
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
|
||||
try {
|
||||
const skinKeys = skins.map(
|
||||
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
|
||||
)
|
||||
const headKeys = skins.map((skin) => `${skin.texture_key}-head`)
|
||||
|
||||
const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([
|
||||
skinPreviewStorage.batchRetrieve(skinKeys),
|
||||
headStorage.batchRetrieve(headKeys),
|
||||
])
|
||||
|
||||
for (let i = 0; i < skins.length; i++) {
|
||||
const skinKey = skinKeys[i]
|
||||
const headKey = headKeys[i]
|
||||
|
||||
const rawCached = cachedSkinPreviews[skinKey]
|
||||
if (rawCached) {
|
||||
const cached: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawCached.forwards),
|
||||
backwards: URL.createObjectURL(rawCached.backwards),
|
||||
}
|
||||
skinBlobUrlMap.set(skinKey, cached)
|
||||
}
|
||||
|
||||
const cachedHead = cachedHeadPreviews[headKey]
|
||||
if (cachedHead) {
|
||||
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
|
||||
}
|
||||
}
|
||||
|
||||
for (const skin of skins) {
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
|
||||
if (skinBlobUrlMap.has(key)) {
|
||||
if (DEBUG_MODE) {
|
||||
const result = skinBlobUrlMap.get(key)!
|
||||
URL.revokeObjectURL(result.forwards)
|
||||
URL.revokeObjectURL(result.backwards)
|
||||
skinBlobUrlMap.delete(key)
|
||||
} else continue
|
||||
}
|
||||
|
||||
const renderer = getSharedRenderer()
|
||||
|
||||
let variant = skin.variant
|
||||
if (variant === 'UNKNOWN') {
|
||||
try {
|
||||
variant = await determineModelType(skin.texture)
|
||||
} catch (error) {
|
||||
console.error(`Failed to determine model type for skin ${key}:`, error)
|
||||
variant = 'CLASSIC'
|
||||
}
|
||||
}
|
||||
|
||||
const modelUrl = getModelUrlForVariant(variant)
|
||||
const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
|
||||
const rawRenderResult = await renderer.renderSkin(
|
||||
await get_normalized_skin_texture(skin),
|
||||
modelUrl,
|
||||
cape?.texture,
|
||||
)
|
||||
|
||||
const renderResult: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawRenderResult.forwards),
|
||||
backwards: URL.createObjectURL(rawRenderResult.backwards),
|
||||
}
|
||||
|
||||
skinBlobUrlMap.set(key, renderResult)
|
||||
|
||||
try {
|
||||
await skinPreviewStorage.store(key, rawRenderResult)
|
||||
} catch (error) {
|
||||
console.warn('Failed to store skin preview in persistent storage:', error)
|
||||
}
|
||||
|
||||
const headKey = `${skin.texture_key}-head`
|
||||
if (!headBlobUrlMap.has(headKey)) {
|
||||
await generateHeadRender(skin)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
disposeSharedRenderer()
|
||||
await cleanupUnusedPreviews(skins)
|
||||
|
||||
await skinPreviewStorage.debugCalculateStorage()
|
||||
await headStorage.debugCalculateStorage()
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ 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
|
||||
|
||||
164
apps/app-frontend/src/helpers/skins.ts
Normal file
164
apps/app-frontend/src/helpers/skins.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { arrayBufferToBase64 } from '@modrinth/utils'
|
||||
|
||||
export interface Cape {
|
||||
id: string
|
||||
name: string
|
||||
texture: string
|
||||
is_default: boolean
|
||||
is_equipped: boolean
|
||||
}
|
||||
|
||||
export type SkinModel = 'CLASSIC' | 'SLIM' | 'UNKNOWN'
|
||||
export type SkinSource = 'default' | 'custom_external' | 'custom'
|
||||
|
||||
export interface Skin {
|
||||
texture_key: string
|
||||
name?: string
|
||||
variant: SkinModel
|
||||
cape_id?: string
|
||||
texture: string
|
||||
source: SkinSource
|
||||
is_equipped: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[]
|
||||
|
||||
export const DEFAULT_MODELS: Record<string, SkinModel> = {
|
||||
Steve: 'CLASSIC',
|
||||
Alex: 'SLIM',
|
||||
Zuri: 'CLASSIC',
|
||||
Sunny: 'CLASSIC',
|
||||
Noor: 'SLIM',
|
||||
Makena: 'SLIM',
|
||||
Kai: 'CLASSIC',
|
||||
Efe: 'SLIM',
|
||||
Ari: 'CLASSIC',
|
||||
}
|
||||
|
||||
export function filterSavedSkins(list: Skin[]) {
|
||||
const customSkins = list.filter((s) => s.source !== 'default')
|
||||
fixUnknownSkins(customSkins).catch(handleError)
|
||||
return customSkins
|
||||
}
|
||||
|
||||
export async function determineModelType(texture: string): Promise<'SLIM' | 'CLASSIC'> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
if (!context) {
|
||||
return reject(new Error('Failed to create canvas rendering context.'))
|
||||
}
|
||||
|
||||
const image = new Image()
|
||||
image.crossOrigin = 'anonymous'
|
||||
image.src = texture
|
||||
|
||||
image.onload = () => {
|
||||
canvas.width = image.width
|
||||
canvas.height = image.height
|
||||
|
||||
context.drawImage(image, 0, 0)
|
||||
|
||||
const armX = 54
|
||||
const armY = 20
|
||||
const armWidth = 2
|
||||
const armHeight = 12
|
||||
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
|
||||
for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) {
|
||||
if (imageData[alphaIndex] !== 0) {
|
||||
resolve('CLASSIC')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
canvas.remove()
|
||||
resolve('SLIM')
|
||||
}
|
||||
|
||||
image.onerror = () => {
|
||||
canvas.remove()
|
||||
reject(new Error('Failed to load the image.'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function fixUnknownSkins(list: Skin[]) {
|
||||
const unknownSkins = list.filter((s) => s.variant === 'UNKNOWN')
|
||||
for (const unknownSkin of unknownSkins) {
|
||||
unknownSkin.variant = await determineModelType(unknownSkin.texture)
|
||||
}
|
||||
}
|
||||
|
||||
export function filterDefaultSkins(list: Skin[]) {
|
||||
return list
|
||||
.filter(
|
||||
(s) =>
|
||||
s.source === 'default' &&
|
||||
(!s.name || !(s.name in DEFAULT_MODELS) || s.variant === DEFAULT_MODELS[s.name]),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1
|
||||
const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1
|
||||
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex)
|
||||
})
|
||||
}
|
||||
|
||||
export async function get_available_capes(): Promise<Cape[]> {
|
||||
return invoke('plugin:minecraft-skins|get_available_capes', {})
|
||||
}
|
||||
|
||||
export async function get_available_skins(): Promise<Skin[]> {
|
||||
return invoke('plugin:minecraft-skins|get_available_skins', {})
|
||||
}
|
||||
|
||||
export async function add_and_equip_custom_skin(
|
||||
textureBlob: Uint8Array,
|
||||
variant: SkinModel,
|
||||
capeOverride?: Cape,
|
||||
): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
|
||||
textureBlob,
|
||||
variant,
|
||||
capeOverride,
|
||||
})
|
||||
}
|
||||
|
||||
export async function set_default_cape(cape?: Cape): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|set_default_cape', {
|
||||
cape,
|
||||
})
|
||||
}
|
||||
|
||||
export async function equip_skin(skin: Skin): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|equip_skin', {
|
||||
skin,
|
||||
})
|
||||
}
|
||||
|
||||
export async function remove_custom_skin(skin: Skin): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|remove_custom_skin', {
|
||||
skin,
|
||||
})
|
||||
}
|
||||
|
||||
export async function get_normalized_skin_texture(skin: Skin): Promise<string> {
|
||||
const data = await normalize_skin_texture(skin.texture)
|
||||
const base64 = arrayBufferToBase64(data)
|
||||
return `data:image/png;base64,${base64}`
|
||||
}
|
||||
|
||||
export async function normalize_skin_texture(texture: Uint8Array | string): Promise<Uint8Array> {
|
||||
return await invoke('plugin:minecraft-skins|normalize_skin_texture', { texture })
|
||||
}
|
||||
|
||||
export async function unequip_skin(): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|unequip_skin')
|
||||
}
|
||||
|
||||
export async function get_dragged_skin_data(path: string): Promise<Uint8Array> {
|
||||
const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
|
||||
return new Uint8Array(data)
|
||||
}
|
||||
229
apps/app-frontend/src/helpers/storage/head-storage.ts
Normal file
229
apps/app-frontend/src/helpers/storage/head-storage.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
interface StoredHead {
|
||||
blob: Blob
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export class HeadStorage {
|
||||
private dbName = 'head-storage'
|
||||
private version = 1
|
||||
private db: IDBDatabase | null = null
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.version)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result
|
||||
resolve()
|
||||
}
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains('heads')) {
|
||||
db.createObjectStore('heads')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async store(key: string, blob: Blob): Promise<void> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['heads'], 'readwrite')
|
||||
const store = transaction.objectStore('heads')
|
||||
|
||||
const storedHead: StoredHead = {
|
||||
blob,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.put(storedHead, key)
|
||||
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async retrieve(key: string): Promise<string | null> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['heads'], 'readonly')
|
||||
const store = transaction.objectStore('heads')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(key)
|
||||
|
||||
request.onsuccess = () => {
|
||||
const result = request.result as StoredHead | undefined
|
||||
|
||||
if (!result) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(result.blob)
|
||||
resolve(url)
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async batchRetrieve(keys: string[]): Promise<Record<string, Blob | null>> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['heads'], 'readonly')
|
||||
const store = transaction.objectStore('heads')
|
||||
const results: Record<string, Blob | null> = {}
|
||||
|
||||
return new Promise((resolve, _reject) => {
|
||||
let completedRequests = 0
|
||||
|
||||
if (keys.length === 0) {
|
||||
resolve(results)
|
||||
return
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const request = store.get(key)
|
||||
|
||||
request.onsuccess = () => {
|
||||
const result = request.result as StoredHead | undefined
|
||||
|
||||
if (result) {
|
||||
results[key] = result.blob
|
||||
} else {
|
||||
results[key] = null
|
||||
}
|
||||
|
||||
completedRequests++
|
||||
if (completedRequests === keys.length) {
|
||||
resolve(results)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => {
|
||||
results[key] = null
|
||||
completedRequests++
|
||||
if (completedRequests === keys.length) {
|
||||
resolve(results)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['heads'], 'readwrite')
|
||||
const store = transaction.objectStore('heads')
|
||||
let deletedCount = 0
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.openCursor()
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
|
||||
|
||||
if (cursor) {
|
||||
const key = cursor.primaryKey as string
|
||||
|
||||
if (!validKeys.has(key)) {
|
||||
const deleteRequest = cursor.delete()
|
||||
deleteRequest.onsuccess = () => {
|
||||
deletedCount++
|
||||
}
|
||||
deleteRequest.onerror = () => {
|
||||
console.warn('Failed to delete invalid head entry:', key)
|
||||
}
|
||||
}
|
||||
|
||||
cursor.continue()
|
||||
} else {
|
||||
resolve(deletedCount)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async debugCalculateStorage(): Promise<void> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['heads'], 'readonly')
|
||||
const store = transaction.objectStore('heads')
|
||||
|
||||
let totalSize = 0
|
||||
let count = 0
|
||||
const entries: Array<{ key: string; size: number }> = []
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.openCursor()
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
|
||||
|
||||
if (cursor) {
|
||||
const key = cursor.primaryKey as string
|
||||
const value = cursor.value as StoredHead
|
||||
|
||||
const entrySize = value.blob.size
|
||||
totalSize += entrySize
|
||||
count++
|
||||
|
||||
entries.push({
|
||||
key,
|
||||
size: entrySize,
|
||||
})
|
||||
|
||||
cursor.continue()
|
||||
} else {
|
||||
console.group('🗄️ Head Storage Debug Info')
|
||||
console.log(`Total entries: ${count}`)
|
||||
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
|
||||
console.log(
|
||||
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
|
||||
)
|
||||
|
||||
if (entries.length > 0) {
|
||||
const sortedEntries = entries.sort((a, b) => b.size - a.size)
|
||||
console.log(
|
||||
'Largest entry:',
|
||||
sortedEntries[0].key,
|
||||
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
|
||||
)
|
||||
console.log(
|
||||
'Smallest entry:',
|
||||
sortedEntries[sortedEntries.length - 1].key,
|
||||
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
|
||||
)
|
||||
}
|
||||
|
||||
console.groupEnd()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['heads'], 'readwrite')
|
||||
const store = transaction.objectStore('heads')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.clear()
|
||||
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const headStorage = new HeadStorage()
|
||||
218
apps/app-frontend/src/helpers/storage/skin-preview-storage.ts
Normal file
218
apps/app-frontend/src/helpers/storage/skin-preview-storage.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import type { RawRenderResult } from '../rendering/batch-skin-renderer'
|
||||
|
||||
interface StoredPreview {
|
||||
forwards: Blob
|
||||
backwards: Blob
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export class SkinPreviewStorage {
|
||||
private dbName = 'skin-previews'
|
||||
private version = 1
|
||||
private db: IDBDatabase | null = null
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.version)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result
|
||||
resolve()
|
||||
}
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains('previews')) {
|
||||
db.createObjectStore('previews')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async store(key: string, result: RawRenderResult): Promise<void> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['previews'], 'readwrite')
|
||||
const store = transaction.objectStore('previews')
|
||||
|
||||
const storedPreview: StoredPreview = {
|
||||
forwards: result.forwards,
|
||||
backwards: result.backwards,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.put(storedPreview, key)
|
||||
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async retrieve(key: string): Promise<RawRenderResult | null> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['previews'], 'readonly')
|
||||
const store = transaction.objectStore('previews')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(key)
|
||||
|
||||
request.onsuccess = () => {
|
||||
const result = request.result as StoredPreview | undefined
|
||||
|
||||
if (!result) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
resolve({ forwards: result.forwards, backwards: result.backwards })
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async batchRetrieve(keys: string[]): Promise<Record<string, RawRenderResult | null>> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['previews'], 'readonly')
|
||||
const store = transaction.objectStore('previews')
|
||||
const results: Record<string, RawRenderResult | null> = {}
|
||||
|
||||
return new Promise((resolve, _reject) => {
|
||||
let completedRequests = 0
|
||||
|
||||
if (keys.length === 0) {
|
||||
resolve(results)
|
||||
return
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const request = store.get(key)
|
||||
|
||||
request.onsuccess = () => {
|
||||
const result = request.result as StoredPreview | undefined
|
||||
|
||||
if (result) {
|
||||
results[key] = { forwards: result.forwards, backwards: result.backwards }
|
||||
} else {
|
||||
results[key] = null
|
||||
}
|
||||
|
||||
completedRequests++
|
||||
if (completedRequests === keys.length) {
|
||||
resolve(results)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => {
|
||||
results[key] = null
|
||||
completedRequests++
|
||||
if (completedRequests === keys.length) {
|
||||
resolve(results)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['previews'], 'readwrite')
|
||||
const store = transaction.objectStore('previews')
|
||||
let deletedCount = 0
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.openCursor()
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
|
||||
|
||||
if (cursor) {
|
||||
const key = cursor.primaryKey as string
|
||||
|
||||
if (!validKeys.has(key)) {
|
||||
const deleteRequest = cursor.delete()
|
||||
deleteRequest.onsuccess = () => {
|
||||
deletedCount++
|
||||
}
|
||||
deleteRequest.onerror = () => {
|
||||
console.warn('Failed to delete invalid entry:', key)
|
||||
}
|
||||
}
|
||||
|
||||
cursor.continue()
|
||||
} else {
|
||||
resolve(deletedCount)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async debugCalculateStorage(): Promise<void> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['previews'], 'readonly')
|
||||
const store = transaction.objectStore('previews')
|
||||
|
||||
let totalSize = 0
|
||||
let count = 0
|
||||
const entries: Array<{ key: string; size: number }> = []
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.openCursor()
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
|
||||
|
||||
if (cursor) {
|
||||
const key = cursor.primaryKey as string
|
||||
const value = cursor.value as StoredPreview
|
||||
|
||||
const entrySize = value.forwards.size + value.backwards.size
|
||||
totalSize += entrySize
|
||||
count++
|
||||
|
||||
entries.push({
|
||||
key,
|
||||
size: entrySize,
|
||||
})
|
||||
|
||||
cursor.continue()
|
||||
} else {
|
||||
console.group('🗄️ Skin Preview Storage Debug Info')
|
||||
console.log(`Total entries: ${count}`)
|
||||
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
|
||||
console.log(
|
||||
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
|
||||
)
|
||||
|
||||
if (entries.length > 0) {
|
||||
const sortedEntries = entries.sort((a, b) => b.size - a.size)
|
||||
console.log(
|
||||
'Largest entry:',
|
||||
sortedEntries[0].key,
|
||||
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
|
||||
)
|
||||
console.log(
|
||||
'Smallest entry:',
|
||||
sortedEntries[sortedEntries.length - 1].key,
|
||||
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
|
||||
)
|
||||
}
|
||||
|
||||
console.groupEnd()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const skinPreviewStorage = new SkinPreviewStorage()
|
||||
@@ -1,22 +1,20 @@
|
||||
import { ref } from 'vue'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { getArtifact, getOS } from '@/helpers/utils.js'
|
||||
|
||||
import { initUpdateLauncher, getOS } from '@/helpers/utils.js'
|
||||
|
||||
export const allowState = ref(false)
|
||||
export const installState = ref(false)
|
||||
export const updateState = ref(false)
|
||||
export const latestBetaCommitTruncatedSha = ref('')
|
||||
export const latestBetaCommitLink = ref('')
|
||||
export const launcherUrl = 'https://www.astralium.su/get/ar'
|
||||
|
||||
const os = ref('')
|
||||
const currentOS = ref('')
|
||||
const releaseLink = `https://git.astralium.su/api/v1/repos/didirus/AstralRinth/releases/latest`
|
||||
const failedFetch = [`Failed to fetch remote releases:`, `Failed to fetch remote commits:`]
|
||||
const osNames = ['macos', 'windows', 'linux']
|
||||
const macExtension = `.dmg` // MacOS file type for download
|
||||
const windowsExtension = `.msi` // Windows file type for download
|
||||
const blacklistedBuilds = [
|
||||
|
||||
const osList = ['macos', 'windows', 'linux']
|
||||
const macExtensionList = ['.dmg', '.pkg']
|
||||
const windowsExtensionList = ['.exe', '.msi']
|
||||
|
||||
const blacklistPrefixes = [
|
||||
`dev`,
|
||||
`nightly`,
|
||||
`dirty`,
|
||||
@@ -26,110 +24,73 @@ const blacklistedBuilds = [
|
||||
`dirty_nightly`,
|
||||
] // This is blacklisted builds for download. For example, file.startsWith('dev') is not allowed.
|
||||
|
||||
/**
|
||||
* Asynchronous function to get remote data and handle updates and downloads.
|
||||
*
|
||||
* @param {boolean} elementIdBool - Indicates whether to disable an element ID.
|
||||
* @param {boolean} downloadArtifactBool - Indicates whether to download an artifact.
|
||||
*/
|
||||
export async function getRemote(elementIdBool, downloadArtifactBool) {
|
||||
fetch(releaseLink)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.status)
|
||||
}
|
||||
return response.json()
|
||||
})
|
||||
.then(async (data) => {
|
||||
os.value = await getOS()
|
||||
const latestRelease = data.name
|
||||
let remoteVersion = undefined
|
||||
export async function getRemote(isDownloadState) {
|
||||
var releaseData = null;
|
||||
var result = false;
|
||||
try {
|
||||
const response = await fetch(releaseLink);
|
||||
if (!response.ok) {
|
||||
throw new Error(response.status);
|
||||
}
|
||||
const remoteData = await response.json();
|
||||
currentOS.value = await getOS();
|
||||
const remoteLatestReleaseTag = remoteData.tag_name;
|
||||
releaseData = document.getElementById('releaseData');
|
||||
const remoteVersion = releaseData ? (releaseData.textContent = remoteLatestReleaseTag) : remoteLatestReleaseTag;
|
||||
|
||||
if (!elementIdBool) {
|
||||
const releaseData = document.getElementById('releaseData')
|
||||
if (releaseData == null) {
|
||||
console.error('Release data element not found.')
|
||||
return false
|
||||
}
|
||||
releaseData.textContent = latestRelease
|
||||
remoteVersion = `${releaseData.textContent}`
|
||||
} else {
|
||||
remoteVersion = latestRelease
|
||||
}
|
||||
if (osNames.includes(os.value.toLowerCase())) {
|
||||
if (remoteVersion.startsWith('v' + await getVersion())) {
|
||||
updateState.value = false
|
||||
allowState.value = false
|
||||
} else {
|
||||
updateState.value = true
|
||||
allowState.value = true
|
||||
}
|
||||
} else {
|
||||
updateState.value = false
|
||||
allowState.value = false
|
||||
}
|
||||
console.log('Update available state is', updateState.value)
|
||||
console.log('Remote version is', remoteVersion)
|
||||
console.log('Local version is', await getVersion())
|
||||
console.log('Operating System is', os.value)
|
||||
if (osList.includes(currentOS.value.toLowerCase())) {
|
||||
const localVersion = await getVersion();
|
||||
const isUpdateAvailable = !remoteVersion.includes(localVersion);
|
||||
|
||||
if (downloadArtifactBool) {
|
||||
installState.value = true
|
||||
const builds = data.assets
|
||||
const fileName = getInstaller(getExtension(), builds)
|
||||
if (fileName != null) {
|
||||
await getArtifact(fileName[1], fileName[0], os.value, true)
|
||||
}
|
||||
installState.value = false
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(failedFetch[0], error)
|
||||
if (!elementIdBool) {
|
||||
const errorData = document.getElementById('releaseData')
|
||||
if (errorData) {
|
||||
errorData.textContent = `${error.message}`
|
||||
}
|
||||
updateState.value = false
|
||||
allowState.value = false
|
||||
installState.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
updateState.value = isUpdateAvailable;
|
||||
allowState.value = isUpdateAvailable;
|
||||
} else {
|
||||
updateState.value = false;
|
||||
allowState.value = false;
|
||||
}
|
||||
if (isDownloadState) {
|
||||
installState.value = true;
|
||||
const builds = remoteData.assets;
|
||||
const fileName = getInstaller(getExtension(), builds);
|
||||
result = fileName ? await initUpdateLauncher(fileName[1], fileName[0], currentOS.value, true) : false;
|
||||
installState.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the installer for a specific operating system.
|
||||
*
|
||||
* @param {string} osExtension - The file extension of the installer.
|
||||
* @param {Array} builds - The list of builds.
|
||||
* @return {Array|null} An array containing the installer name and URL if found, or null if not found.
|
||||
*/
|
||||
function getInstaller(osExtension, builds) {
|
||||
for (let i of builds) {
|
||||
let blacklistedItem = false
|
||||
blacklistedBuilds.forEach((item) => {
|
||||
if (i.name.startsWith(item)) {
|
||||
return (blacklistedItem = true)
|
||||
console.log('Update available state is', updateState.value);
|
||||
console.log('Remote version is', remoteVersion);
|
||||
console.log('Local version is', await getVersion());
|
||||
console.log('Operating System is', currentOS.value);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(failedFetch[0], error);
|
||||
if (!releaseData) {
|
||||
const errorData = document.getElementById('releaseData');
|
||||
if (errorData) {
|
||||
errorData.textContent = `${error.message}`;
|
||||
}
|
||||
})
|
||||
if (i.name.endsWith(osExtension) && !blacklistedItem) {
|
||||
console.log(i.browser_download_url)
|
||||
return [i.name, i.browser_download_url]
|
||||
updateState.value = false;
|
||||
allowState.value = false;
|
||||
installState.value = false;
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A function to get the extension based on the operating system.
|
||||
*
|
||||
* @return {string} The extension based on the operating system.
|
||||
*/
|
||||
function getExtension() {
|
||||
if (os.value.toLowerCase() == osNames[0]) {
|
||||
return macExtension
|
||||
} else if (os.value.toLowerCase() == osNames[1]) {
|
||||
return windowsExtension
|
||||
function getInstaller(osExtension, builds) {
|
||||
console.log(osExtension, builds)
|
||||
for (const build of builds) {
|
||||
if (blacklistPrefixes.some(prefix => build.name.startsWith(prefix))) {
|
||||
continue;
|
||||
}
|
||||
if (osExtension.some(ext => build.name.endsWith(ext))) {
|
||||
console.log(build.name, build.browser_download_url);
|
||||
return [build.name, build.browser_download_url];
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getExtension() {
|
||||
return osList.find(osName => osName === currentOS.value.toLowerCase())?.endsWith('macos')
|
||||
? macExtensionList
|
||||
: windowsExtensionList;
|
||||
}
|
||||
|
||||
@@ -10,9 +10,20 @@ export async function getOS() {
|
||||
return await invoke('plugin:utils|get_os')
|
||||
}
|
||||
|
||||
export async function getArtifact(downloadurl, filename, ostype, autoupdatesupported) {
|
||||
console.log('Downloading build', downloadurl, filename, ostype, autoupdatesupported)
|
||||
return await invoke('plugin:utils|get_artifact', { downloadurl, filename, ostype, autoupdatesupported })
|
||||
// [AR] Feature. Updater
|
||||
export async function initUpdateLauncher(downloadUrl, filename, osType, autoUpdateSupported) {
|
||||
console.log('Downloading build', downloadUrl, filename, osType, autoUpdateSupported)
|
||||
return await invoke('plugin:utils|init_update_launcher', { downloadUrl, filename, osType, autoUpdateSupported })
|
||||
}
|
||||
|
||||
// [AR] Migration. Patch
|
||||
export async function applyMigrationFix(eol) {
|
||||
return await invoke('plugin:utils|apply_migration_fix', { eol })
|
||||
}
|
||||
|
||||
// [AR] Feature. Ely.by
|
||||
export async function initAuthlibPatching(minecraftVersion, isMojang) {
|
||||
return await invoke('plugin:utils|init_authlib_patching', { minecraftVersion, isMojang })
|
||||
}
|
||||
|
||||
export async function openPath(path) {
|
||||
|
||||
@@ -377,6 +377,12 @@
|
||||
"instance.worlds.hardcore": {
|
||||
"message": "Hardcore mode"
|
||||
},
|
||||
"instance.worlds.incompatible_server": {
|
||||
"message": "Server is incompatible"
|
||||
},
|
||||
"instance.worlds.no_contact": {
|
||||
"message": "Server couldn't be contacted"
|
||||
},
|
||||
"instance.worlds.no_quick_play": {
|
||||
"message": "You can only jump straight into worlds on Minecraft 1.20+"
|
||||
},
|
||||
|
||||
@@ -220,6 +220,7 @@ async function refreshSearch() {
|
||||
}
|
||||
}
|
||||
results.value = rawResults.result
|
||||
currentPage.value = 1
|
||||
|
||||
const persistentParams: LocationQuery = {}
|
||||
|
||||
@@ -265,6 +266,7 @@ async function onSearchChangeToTop() {
|
||||
|
||||
function clearSearch() {
|
||||
query.value = ''
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
@@ -10,6 +10,7 @@ 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()
|
||||
@@ -82,13 +83,15 @@ async function refreshFeaturedProjects() {
|
||||
await fetchInstances()
|
||||
await refreshFeaturedProjects()
|
||||
|
||||
const unlistenProfile = await profile_listener(async (e) => {
|
||||
await fetchInstances()
|
||||
const unlistenProfile = await profile_listener(
|
||||
async (e: { event: string; profile_path_id: string }) => {
|
||||
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()
|
||||
@@ -97,8 +100,8 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="p-6 flex flex-col gap-2">
|
||||
<h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
|
||||
<h1 v-else class="m-0 text-2xl">Welcome to AstralRinth App!</h1>
|
||||
<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 AstralRinth App!</h1>
|
||||
<RecentWorldsList :recent-instances="recentInstances" />
|
||||
<RowDisplay
|
||||
v-if="hasFeaturedProjects"
|
||||
|
||||
521
apps/app-frontend/src/pages/Skins.vue
Normal file
521
apps/app-frontend/src/pages/Skins.vue
Normal file
@@ -0,0 +1,521 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
EditIcon,
|
||||
ExcitedRinthbot,
|
||||
LogInIcon,
|
||||
PlusIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
UpdatedIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
ButtonStyled,
|
||||
ConfirmModal,
|
||||
SkinButton,
|
||||
SkinLikeTextButton,
|
||||
SkinPreviewRenderer,
|
||||
} from '@modrinth/ui'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
|
||||
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
|
||||
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
|
||||
import { handleError, useNotifications } from '@/store/notifications'
|
||||
import type { Cape, Skin } from '@/helpers/skins.ts'
|
||||
import {
|
||||
normalize_skin_texture,
|
||||
equip_skin,
|
||||
filterDefaultSkins,
|
||||
filterSavedSkins,
|
||||
get_available_capes,
|
||||
get_available_skins,
|
||||
get_normalized_skin_texture,
|
||||
remove_custom_skin,
|
||||
set_default_cape,
|
||||
} from '@/helpers/skins.ts'
|
||||
import { get as getSettings } from '@/helpers/settings.ts'
|
||||
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
|
||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import { arrayBufferToBase64 } from '@modrinth/utils'
|
||||
const editSkinModal = useTemplateRef('editSkinModal')
|
||||
const selectCapeModal = useTemplateRef('selectCapeModal')
|
||||
const uploadSkinModal = useTemplateRef('uploadSkinModal')
|
||||
|
||||
const notifications = useNotifications()
|
||||
|
||||
const settings = ref(await getSettings())
|
||||
const skins = ref<Skin[]>([])
|
||||
const capes = ref<Cape[]>([])
|
||||
|
||||
const accountsCard = inject('accountsCard') as Ref<typeof AccountsCard>
|
||||
const currentUser = ref(undefined)
|
||||
const currentUserId = ref<string | undefined>(undefined)
|
||||
|
||||
const username = computed(() => currentUser.value?.profile?.name ?? undefined)
|
||||
const selectedSkin = ref<Skin | null>(null)
|
||||
const defaultCape = ref<Cape>()
|
||||
|
||||
const originalSelectedSkin = ref<Skin | null>(null)
|
||||
const originalDefaultCape = ref<Cape>()
|
||||
|
||||
const savedSkins = computed(() => filterSavedSkins(skins.value))
|
||||
const defaultSkins = computed(() => filterDefaultSkins(skins.value))
|
||||
|
||||
const currentCape = computed(() => {
|
||||
if (selectedSkin.value?.cape_id) {
|
||||
const overrideCape = capes.value.find((c) => c.id === selectedSkin.value?.cape_id)
|
||||
if (overrideCape) {
|
||||
return overrideCape
|
||||
}
|
||||
}
|
||||
return defaultCape.value
|
||||
})
|
||||
|
||||
const skinTexture = computedAsync(async () => {
|
||||
if (selectedSkin.value?.texture) {
|
||||
return await get_normalized_skin_texture(selectedSkin.value)
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
const capeTexture = computed(() => currentCape.value?.texture)
|
||||
const skinVariant = computed(() => selectedSkin.value?.variant)
|
||||
const skinNametag = computed(() =>
|
||||
settings.value.hide_nametag_skins_page ? undefined : username.value,
|
||||
)
|
||||
|
||||
let userCheckInterval: number | null = null
|
||||
|
||||
const deleteSkinModal = ref()
|
||||
const skinToDelete = ref<Skin | null>(null)
|
||||
|
||||
function confirmDeleteSkin(skin: Skin) {
|
||||
skinToDelete.value = skin
|
||||
deleteSkinModal.value?.show()
|
||||
}
|
||||
|
||||
async function deleteSkin() {
|
||||
if (!skinToDelete.value) return
|
||||
await remove_custom_skin(skinToDelete.value).catch(handleError)
|
||||
await loadSkins()
|
||||
skinToDelete.value = null
|
||||
}
|
||||
|
||||
async function loadCapes() {
|
||||
try {
|
||||
capes.value = (await get_available_capes()) ?? []
|
||||
defaultCape.value = capes.value.find((c) => c.is_equipped)
|
||||
originalDefaultCape.value = defaultCape.value
|
||||
} catch (error) {
|
||||
if (currentUser.value) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkins() {
|
||||
try {
|
||||
skins.value = (await get_available_skins()) ?? []
|
||||
generateSkinPreviews(skins.value, capes.value)
|
||||
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
|
||||
originalSelectedSkin.value = selectedSkin.value
|
||||
} catch (error) {
|
||||
if (currentUser.value) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSkin(newSkin: Skin) {
|
||||
const previousSkin = selectedSkin.value
|
||||
const previousSkinsList = [...skins.value]
|
||||
|
||||
skins.value = skins.value.map((skin) => {
|
||||
return {
|
||||
...skin,
|
||||
is_equipped: skin.texture_key === newSkin.texture_key,
|
||||
}
|
||||
})
|
||||
|
||||
selectedSkin.value = skins.value.find((s) => s.texture_key === newSkin.texture_key) || null
|
||||
|
||||
try {
|
||||
await equip_skin(newSkin)
|
||||
if (accountsCard.value) {
|
||||
await accountsCard.value.refreshValues()
|
||||
}
|
||||
} catch (error) {
|
||||
selectedSkin.value = previousSkin
|
||||
skins.value = previousSkinsList
|
||||
|
||||
if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) {
|
||||
notifications.addNotification({
|
||||
type: 'error',
|
||||
title: 'Slow down!',
|
||||
text: "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
|
||||
})
|
||||
} else {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCapeSelected(cape: Cape | undefined) {
|
||||
const previousDefaultCape = defaultCape.value
|
||||
const previousCapesList = [...capes.value]
|
||||
|
||||
capes.value = capes.value.map((c) => ({
|
||||
...c,
|
||||
is_equipped: cape ? c.id === cape.id : false,
|
||||
}))
|
||||
|
||||
defaultCape.value = cape ? capes.value.find((c) => c.id === cape.id) : undefined
|
||||
|
||||
try {
|
||||
await set_default_cape(cape)
|
||||
} catch (error) {
|
||||
defaultCape.value = previousDefaultCape
|
||||
capes.value = previousCapesList
|
||||
|
||||
if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) {
|
||||
notifications.addNotification({
|
||||
type: 'error',
|
||||
title: 'Slow down!',
|
||||
text: "You're changing your cape too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
|
||||
})
|
||||
} else {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onSkinSaved() {
|
||||
await Promise.all([loadCapes(), loadSkins()])
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
try {
|
||||
const defaultId = await get_default_user()
|
||||
currentUserId.value = defaultId
|
||||
|
||||
const allAccounts = await users()
|
||||
currentUser.value = allAccounts.find((acc) => acc.profile.id === defaultId)
|
||||
} catch (e) {
|
||||
handleError(e)
|
||||
currentUser.value = undefined
|
||||
currentUserId.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
return skinBlobUrlMap.get(key)
|
||||
}
|
||||
|
||||
async function login() {
|
||||
accountsCard.value.setLoginDisabled(true)
|
||||
const loggedIn = await login_flow().catch(handleSevereError)
|
||||
|
||||
if (loggedIn && accountsCard) {
|
||||
await accountsCard.value.refreshValues()
|
||||
}
|
||||
|
||||
trackEvent('AccountLogIn')
|
||||
accountsCard.value.setLoginDisabled(false)
|
||||
}
|
||||
|
||||
function openUploadSkinModal(e: MouseEvent) {
|
||||
uploadSkinModal.value?.show(e)
|
||||
}
|
||||
|
||||
function onSkinFileUploaded(buffer: ArrayBuffer) {
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
normalize_skin_texture(`data:image/png;base64,` + arrayBufferToBase64(buffer)).then(
|
||||
(skinTextureNormalized: Uint8Array) => {
|
||||
const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized)
|
||||
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
|
||||
editSkinModal.value.restoreWithNewTexture(skinTexUrl)
|
||||
} else {
|
||||
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function onUploadCanceled() {
|
||||
editSkinModal.value?.restoreModal()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selectedSkin.value?.cape_id,
|
||||
() => {},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
userCheckInterval = window.setInterval(checkUserChanges, 250)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (userCheckInterval !== null) {
|
||||
window.clearInterval(userCheckInterval)
|
||||
}
|
||||
})
|
||||
|
||||
async function checkUserChanges() {
|
||||
try {
|
||||
const defaultId = await get_default_user()
|
||||
if (defaultId !== currentUserId.value) {
|
||||
await loadCurrentUser()
|
||||
await loadCapes()
|
||||
await loadSkins()
|
||||
}
|
||||
} catch (error) {
|
||||
if (currentUser.value) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EditSkinModal
|
||||
ref="editSkinModal"
|
||||
:capes="capes"
|
||||
:default-cape="defaultCape"
|
||||
@saved="onSkinSaved"
|
||||
@deleted="() => loadSkins()"
|
||||
@open-upload-modal="openUploadSkinModal"
|
||||
/>
|
||||
<SelectCapeModal ref="selectCapeModal" :capes="capes" @select="handleCapeSelected" />
|
||||
<UploadSkinModal
|
||||
ref="uploadSkinModal"
|
||||
@uploaded="onSkinFileUploaded"
|
||||
@canceled="onUploadCanceled"
|
||||
/>
|
||||
<ConfirmModal
|
||||
ref="deleteSkinModal"
|
||||
title="Are you sure you want to delete this skin?"
|
||||
description="This will permanently delete the selected skin. This action cannot be undone."
|
||||
proceed-label="Delete"
|
||||
@proceed="deleteSkin"
|
||||
/>
|
||||
|
||||
<div v-if="currentUser" class="p-4 skin-layout">
|
||||
<div class="preview-panel">
|
||||
<h1 class="m-0 text-2xl font-bold flex items-center gap-2">
|
||||
Skins
|
||||
<span class="text-sm font-bold px-2 bg-brand-highlight text-brand rounded-full">Beta</span>
|
||||
</h1>
|
||||
<div class="preview-container">
|
||||
<SkinPreviewRenderer
|
||||
:cape-src="capeTexture"
|
||||
:texture-src="skinTexture || ''"
|
||||
:variant="skinVariant"
|
||||
:nametag="skinNametag"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
>
|
||||
<template #subtitle>
|
||||
<ButtonStyled :disabled="!!selectedSkin?.cape_id">
|
||||
<button
|
||||
v-tooltip="
|
||||
selectedSkin?.cape_id
|
||||
? 'The equipped skin is overriding the default cape.'
|
||||
: undefined
|
||||
"
|
||||
:disabled="!!selectedSkin?.cape_id"
|
||||
@click="
|
||||
(e: MouseEvent) =>
|
||||
selectCapeModal?.show(
|
||||
e,
|
||||
selectedSkin?.texture_key,
|
||||
currentCape,
|
||||
skinTexture,
|
||||
skinVariant,
|
||||
)
|
||||
"
|
||||
>
|
||||
<UpdatedIcon />
|
||||
Change cape
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinPreviewRenderer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skins-container">
|
||||
<section class="flex flex-col gap-2 mt-1">
|
||||
<h2 class="text-lg font-bold m-0 text-primary">Saved skins</h2>
|
||||
<div class="skin-card-grid">
|
||||
<SkinLikeTextButton class="skin-card" @click="openUploadSkinModal">
|
||||
<template #icon>
|
||||
<PlusIcon class="size-8" />
|
||||
</template>
|
||||
<span>Add a skin</span>
|
||||
</SkinLikeTextButton>
|
||||
|
||||
<SkinButton
|
||||
v-for="skin in savedSkins"
|
||||
:key="`saved-skin-${skin.texture_key}`"
|
||||
class="skin-card"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="selectedSkin === skin"
|
||||
@select="changeSkin(skin)"
|
||||
>
|
||||
<template #overlay-buttons>
|
||||
<Button
|
||||
color="green"
|
||||
aria-label="Edit skin"
|
||||
class="pointer-events-auto"
|
||||
@click.stop="(e) => editSkinModal?.show(e, skin)"
|
||||
>
|
||||
<EditIcon /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
v-show="!skin.is_equipped"
|
||||
v-tooltip="'Delete skin'"
|
||||
aria-label="Delete skin"
|
||||
color="red"
|
||||
class="!rounded-[100%] pointer-events-auto"
|
||||
icon-only
|
||||
@click.stop="() => confirmDeleteSkin(skin)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2 mt-6">
|
||||
<h2 class="text-lg font-bold m-0 text-primary">Default skins</h2>
|
||||
<div class="skin-card-grid">
|
||||
<SkinButton
|
||||
v-for="skin in defaultSkins"
|
||||
:key="`default-skin-${skin.texture_key}`"
|
||||
class="skin-card"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="selectedSkin === skin"
|
||||
:tooltip="skin.name"
|
||||
@select="changeSkin(skin)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center justify-center min-h-[50vh] pt-[25%]">
|
||||
<div
|
||||
class="bg-bg-raised rounded-lg p-7 flex flex-col gap-5 shadow-md relative max-w-xl w-full mx-auto"
|
||||
>
|
||||
<img
|
||||
:src="ExcitedRinthbot"
|
||||
alt="Excited Modrinth Bot"
|
||||
class="absolute -top-28 right-8 md:right-20 h-28 w-auto"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-[1px] opacity-40 bg-gradient-to-r from-transparent via-green-500 to-transparent"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 2rem,
|
||||
var(--color-green) calc(100% - 13rem),
|
||||
var(--color-green) calc(100% - 5rem),
|
||||
transparent calc(100% - 2rem)
|
||||
);
|
||||
"
|
||||
></div>
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
<h1 class="text-3xl font-extrabold m-0">Please sign-in</h1>
|
||||
<p class="text-lg m-0">
|
||||
Please sign into your Minecraft account to use the skin management features of the
|
||||
Modrinth app.
|
||||
</p>
|
||||
<ButtonStyled v-show="accountsCard" color="brand" :disabled="accountsCard.loginDisabled">
|
||||
<button :disabled="accountsCard.loginDisabled" @click="login">
|
||||
<LogInIcon v-if="!accountsCard.loginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
Sign In
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$skin-card-width: 155px;
|
||||
$skin-card-gap: 4px;
|
||||
|
||||
.skin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2.5fr);
|
||||
gap: 2.5rem;
|
||||
|
||||
@media (max-width: 700px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
top: 1.5rem;
|
||||
position: sticky;
|
||||
align-self: start;
|
||||
padding: 0.5rem;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
height: 80vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: calc((2.5rem / 2));
|
||||
|
||||
@media (max-width: 700px) {
|
||||
height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
.skins-container {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.skin-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: $skin-card-gap;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 1300px) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1750px) {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 2050px) {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.skin-card {
|
||||
aspect-ratio: 0.95;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
import Index from './Index.vue'
|
||||
import Browse from './Browse.vue'
|
||||
import Worlds from './Worlds.vue'
|
||||
import Skins from './Skins.vue'
|
||||
|
||||
export { Index, Browse, Worlds }
|
||||
export { Index, Browse, Worlds, Skins }
|
||||
|
||||
@@ -483,7 +483,7 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 11rem);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
|
||||
@@ -34,6 +34,14 @@ export default new createRouter({
|
||||
breadcrumb: [{ name: 'Discover content' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/skins',
|
||||
name: 'Skins',
|
||||
component: Pages.Skins,
|
||||
meta: {
|
||||
breadcrumb: [{ name: 'Skins' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/library',
|
||||
name: 'Library',
|
||||
|
||||
@@ -41,6 +41,7 @@ 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,6 +10,7 @@
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
"strict": true
|
||||
},
|
||||
|
||||
@@ -4,6 +4,8 @@ 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/
|
||||
@@ -41,17 +43,32 @@ 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_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
target: process.env.TAURI_ENV_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
// don't minify for debug builds
|
||||
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
// produce sourcemaps for debug builds
|
||||
sourcemap: !!process.env.TAURI_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
sourcemap: !!process.env.TAURI_ENV_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
commonjsOptions: {
|
||||
esmExternals: true,
|
||||
},
|
||||
|
||||
@@ -27,7 +27,10 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
||||
|
||||
let credentials = minecraft_auth::finish_login(&input, login).await?;
|
||||
|
||||
println!("Logged in user {}.", credentials.username);
|
||||
println!(
|
||||
"Logged in user {}.",
|
||||
credentials.maybe_online_profile().await.name
|
||||
);
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
|
||||
4
apps/app/.gitignore
vendored
4
apps/app/.gitignore
vendored
@@ -1,6 +1,2 @@
|
||||
# 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 = "0.9.5"
|
||||
version = "1.0.0-local" # The actual version is set by the theseus-build workflow on tagging
|
||||
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,13 +17,14 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_with.workspace = true
|
||||
|
||||
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] }
|
||||
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-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
|
||||
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -18,5 +18,25 @@
|
||||
<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>
|
||||
|
||||
@@ -13,6 +13,8 @@ fn main() {
|
||||
InlinedPlugin::new()
|
||||
.commands(&[
|
||||
"offline_login",
|
||||
"elyby_login",
|
||||
"elyby_auth_authenticate",
|
||||
"login",
|
||||
"remove_user",
|
||||
"get_default_user",
|
||||
@@ -100,6 +102,24 @@ 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()
|
||||
@@ -152,7 +172,6 @@ fn main() {
|
||||
"profile_update_managed_modrinth_version",
|
||||
"profile_repair_managed_modrinth",
|
||||
"profile_run",
|
||||
"profile_run_credentials",
|
||||
"profile_kill",
|
||||
"profile_edit",
|
||||
"profile_edit_icon",
|
||||
@@ -201,7 +220,9 @@ fn main() {
|
||||
"utils",
|
||||
InlinedPlugin::new()
|
||||
.commands(&[
|
||||
"get_artifact",
|
||||
"init_authlib_patching",
|
||||
"apply_migration_fix",
|
||||
"init_update_launcher",
|
||||
"get_os",
|
||||
"should_disable_mouseover",
|
||||
"highlight_in_folder",
|
||||
|
||||
@@ -19,12 +19,21 @@
|
||||
"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",
|
||||
|
||||
@@ -2,12 +2,15 @@ use crate::api::Result;
|
||||
use chrono::{Duration, Utc};
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use tauri::{Manager, Runtime, UserAttentionType};
|
||||
use tauri_plugin_http::reqwest::Client;
|
||||
use theseus::prelude::*;
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
tauri::plugin::Builder::<R>::new("auth")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
offline_login,
|
||||
elyby_login,
|
||||
elyby_auth_authenticate,
|
||||
login,
|
||||
remove_user,
|
||||
get_default_user,
|
||||
@@ -17,14 +20,65 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
.build()
|
||||
}
|
||||
|
||||
/// ### AR • Feature
|
||||
/// Create new offline user
|
||||
/// This is custom function from Astralium Org.
|
||||
#[tauri::command]
|
||||
pub async fn offline_login(name: &str) -> Result<Credentials> {
|
||||
let credentials = minecraft_auth::offline_auth(name).await?;
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
/// ### AR • Feature
|
||||
/// Create new Ely.by user
|
||||
#[tauri::command]
|
||||
pub async fn elyby_login(
|
||||
uuid: uuid::Uuid,
|
||||
login: &str,
|
||||
access_token: &str
|
||||
) -> Result<Credentials> {
|
||||
let credentials = minecraft_auth::elyby_auth(uuid, login, access_token).await?;
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
/// ### AR • Feature
|
||||
/// Authenticate Ely.by user
|
||||
#[tauri::command]
|
||||
pub async fn elyby_auth_authenticate(
|
||||
login: &str,
|
||||
password: &str,
|
||||
client_token: &str,
|
||||
) -> Result<String> {
|
||||
let client = Client::new();
|
||||
let auth_body = serde_json::json!({
|
||||
"username": login,
|
||||
"password": password,
|
||||
"clientToken": client_token,
|
||||
});
|
||||
|
||||
let response = match client
|
||||
.post("https://authserver.ely.by/auth/authenticate")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&auth_body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
tracing::error!("[AR] • Failed to send request: {}", e);
|
||||
return Ok("".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let text = match response.text().await {
|
||||
Ok(body) => body,
|
||||
Err(e) => {
|
||||
tracing::error!("[AR] • Failed to read response text: {}", e);
|
||||
return Ok("".to_string());
|
||||
}
|
||||
};
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Authenticate a user with Hydra - part 1
|
||||
/// This begins the authentication flow quasi-synchronously, returning a URL to visit (that the user will sign in at)
|
||||
#[tauri::command]
|
||||
|
||||
@@ -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<Option<JavaVersion>> {
|
||||
jre::check_jre(path).await.map_err(|e| e.into())
|
||||
pub async fn jre_get_jre(path: PathBuf) -> Result<JavaVersion> {
|
||||
Ok(jre::check_jre(path).await?)
|
||||
}
|
||||
|
||||
// Tests JRE of a certain version
|
||||
|
||||
104
apps/app/src/api/minecraft_skins.rs
Normal file
104
apps/app/src/api/minecraft_skins.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
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,6 +7,7 @@ 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,7 +28,6 @@ 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,
|
||||
@@ -256,22 +255,6 @@ 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?;
|
||||
|
||||
@@ -10,12 +10,15 @@ use crate::api::{Result, TheseusSerializableError};
|
||||
use dashmap::DashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use theseus::prelude::canonicalize;
|
||||
use theseus::util::utils;
|
||||
use url::Url;
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("utils")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_artifact,
|
||||
init_authlib_patching,
|
||||
apply_migration_fix,
|
||||
init_update_launcher,
|
||||
get_os,
|
||||
should_disable_mouseover,
|
||||
highlight_in_folder,
|
||||
@@ -27,9 +30,39 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
.build()
|
||||
}
|
||||
|
||||
/// [AR] Feature. Ely.by
|
||||
#[tauri::command]
|
||||
pub async fn get_artifact(downloadurl: &str, filename: &str, ostype: &str, autoupdatesupported: bool) -> Result<()> {
|
||||
theseus::download::init_download(downloadurl, filename, ostype, autoupdatesupported).await;
|
||||
pub async fn init_authlib_patching(
|
||||
minecraft_version: &str,
|
||||
is_mojang: bool,
|
||||
) -> Result<bool> {
|
||||
let result =
|
||||
utils::init_authlib_patching(minecraft_version, is_mojang).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// [AR] Migration. Patch
|
||||
#[tauri::command]
|
||||
pub async fn apply_migration_fix(eol: &str) -> Result<bool> {
|
||||
let result = utils::apply_migration_fix(eol).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// [AR] Feature. Updater
|
||||
#[tauri::command]
|
||||
pub async fn init_update_launcher(
|
||||
download_url: &str,
|
||||
filename: &str,
|
||||
os_type: &str,
|
||||
auto_update_supported: bool,
|
||||
) -> Result<()> {
|
||||
let _ = utils::init_update_launcher(
|
||||
download_url,
|
||||
filename,
|
||||
os_type,
|
||||
auto_update_supported,
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -34,9 +34,6 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
|
||||
|
||||
// let update_fut = updater.check();
|
||||
|
||||
// tracing::info!("Initializing app state...");
|
||||
State::init().await?;
|
||||
|
||||
// let check_bar = theseus::init_loading(
|
||||
// theseus::LoadingBarType::CheckingForUpdates,
|
||||
// 1.0,
|
||||
@@ -87,7 +84,7 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
|
||||
// #[cfg(not(feature = "updater"))]
|
||||
// {
|
||||
// }
|
||||
|
||||
tracing::info!("Initializing app state...");
|
||||
State::init().await?;
|
||||
tracing::info!("AstralRinth state successfully initialized.");
|
||||
let state = State::get().await?;
|
||||
@@ -160,14 +157,14 @@ fn main() {
|
||||
*/
|
||||
let _log_guard = theseus::start_logger();
|
||||
|
||||
tracing::info!("Initialized tracing subscriber. Loading Modrinth App!");
|
||||
tracing::info!("Initialized tracing subscriber. Loading AstralRinth App!");
|
||||
|
||||
let mut builder = tauri::Builder::default();
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
}
|
||||
// #[cfg(feature = "updater")]
|
||||
// {
|
||||
// builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
// }
|
||||
|
||||
builder = builder
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
@@ -183,6 +180,7 @@ 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())
|
||||
@@ -261,6 +259,7 @@ 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())
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
"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"
|
||||
@@ -30,7 +27,6 @@
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "",
|
||||
"linux": {
|
||||
"deb": {
|
||||
@@ -45,7 +41,7 @@
|
||||
]
|
||||
},
|
||||
"productName": "AstralRinth App",
|
||||
"version": "0.9.501",
|
||||
"version": "0.10.305",
|
||||
"mainBinaryName": "AstralRinth App",
|
||||
"identifier": "AstralRinthApp",
|
||||
"plugins": {
|
||||
@@ -90,9 +86,9 @@
|
||||
"capabilities": ["core", "plugins"],
|
||||
"csp": {
|
||||
"default-src": "'self' customprotocol: asset:",
|
||||
"connect-src": "https://git.astralium.su ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs",
|
||||
"connect-src": "ipc: https://git.astralium.su https://authserver.ely.by http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:",
|
||||
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:",
|
||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net 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,5 +1,4 @@
|
||||
FROM rust:1.87.0 AS build
|
||||
ENV PKG_CONFIG_ALLOW_CROSS=1
|
||||
FROM rust:1.88.0 AS build
|
||||
|
||||
WORKDIR /usr/src/daedalus
|
||||
COPY . .
|
||||
@@ -10,11 +9,8 @@ FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN update-ca-certificates
|
||||
|
||||
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
||||
WORKDIR /daedalus_client
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
Support: https://support.modrinth.com
|
||||
Status page: https://status.modrinth.com
|
||||
Roadmap: https://roadmap.modrinth.com
|
||||
Blog and newsletter: https://blog.modrinth.com/subscribe?utm_medium=social&utm_source=discord&utm_campaign=welcome
|
||||
Blog and newsletter: https://modrinth.com/news
|
||||
API documentation: https://docs.modrinth.com
|
||||
Modrinth source code: https://github.com/modrinth
|
||||
Help translate Modrinth: https://crowdin.com/project/modrinth
|
||||
|
||||
@@ -19,8 +19,6 @@ From there, you can create the database and perform all database migrations with
|
||||
sqlx database setup
|
||||
```
|
||||
|
||||
Finally, if on Linux, you will need the OpenSSL library. On Debian-based systems, this involves the `pkg-config` and `libssl-dev` packages.
|
||||
|
||||
To enable labrinth to create a project, you need to add two things.
|
||||
|
||||
1. An entry in the `loaders` table.
|
||||
@@ -85,11 +83,10 @@ During development, you might notice that changes made directly to entities in t
|
||||
|
||||
#### CDN options
|
||||
|
||||
`STORAGE_BACKEND`: Controls what storage backend is used. This can be either `local`, `backblaze`, or `s3`, but defaults to `local`
|
||||
`STORAGE_BACKEND`: Controls what storage backend is used. This can be either `local` or `s3`, but defaults to `local`
|
||||
|
||||
The Backblaze and S3 configuration options are fairly self-explanatory in name, so here's simply their names:
|
||||
`BACKBLAZE_KEY_ID`, `BACKBLAZE_KEY`, `BACKBLAZE_BUCKET_ID`
|
||||
`S3_ACCESS_TOKEN`, `S3_SECRET`, `S3_URL`, `S3_REGION`, `S3_BUCKET_NAME`
|
||||
The S3 configuration options are fairly self-explanatory in name, so here's simply their names:
|
||||
`S3_ACCESS_TOKEN`, `S3_SECRET`, `S3_URL`, `S3_REGION`, `S3_PUBLIC_BUCKET_NAME`, `S3_PRIVATE_BUCKET_NAME`, `S3_USES_PATH_STYLE_BUCKETS`
|
||||
|
||||
#### Search, OAuth, and miscellaneous options
|
||||
|
||||
|
||||
@@ -38,9 +38,12 @@
|
||||
"@intercom/messenger-js-sdk": "^0.0.14",
|
||||
"@ltd/j-toml": "^1.38.0",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/blog": "workspace:*",
|
||||
"@modrinth/moderation": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"ace-builds": "^1.36.2",
|
||||
@@ -56,10 +59,10 @@
|
||||
"markdown-it": "14.1.0",
|
||||
"pathe": "^1.1.2",
|
||||
"pinia": "^2.1.7",
|
||||
"prettier": "^3.6.2",
|
||||
"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",
|
||||
|
||||
@@ -197,13 +197,13 @@
|
||||
}
|
||||
|
||||
> :where(
|
||||
input + *,
|
||||
.input-group + *,
|
||||
.textarea-wrapper + *,
|
||||
.chips + *,
|
||||
.resizable-textarea-wrapper + *,
|
||||
.input-div + *
|
||||
) {
|
||||
input + *,
|
||||
.input-group + *,
|
||||
.textarea-wrapper + *,
|
||||
.chips + *,
|
||||
.resizable-textarea-wrapper + *,
|
||||
.input-div + *
|
||||
) {
|
||||
&:not(:empty) {
|
||||
margin-block-start: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
@@ -115,10 +115,12 @@ html {
|
||||
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15);
|
||||
|
||||
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
|
||||
--shadow-raised: 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
|
||||
--shadow-raised:
|
||||
0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
|
||||
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
|
||||
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
|
||||
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||
--shadow-floating:
|
||||
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
|
||||
|
||||
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
|
||||
@@ -150,8 +152,8 @@ html {
|
||||
rgba(255, 255, 255, 0.35) 0%,
|
||||
rgba(255, 255, 255, 0.2695) 100%
|
||||
);
|
||||
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16),
|
||||
inset 2px 2px 64px rgba(255, 255, 255, 0.45);
|
||||
--landing-blob-shadow:
|
||||
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(255, 255, 255, 0.45);
|
||||
|
||||
--landing-card-bg: rgba(255, 255, 255, 0.8);
|
||||
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
||||
@@ -251,13 +253,15 @@ html {
|
||||
|
||||
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
|
||||
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
|
||||
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||
--shadow-floating:
|
||||
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
|
||||
|
||||
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
|
||||
|
||||
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp");
|
||||
--landing-maze-gradient-bg: linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
|
||||
--landing-maze-gradient-bg:
|
||||
linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
|
||||
url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
|
||||
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
|
||||
|
||||
@@ -284,7 +288,8 @@ html {
|
||||
rgba(44, 48, 79, 0.35) 0%,
|
||||
rgba(32, 35, 50, 0.2695) 100%
|
||||
);
|
||||
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
|
||||
--landing-blob-shadow:
|
||||
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
|
||||
|
||||
--landing-card-bg: rgba(59, 63, 85, 0.15);
|
||||
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
||||
@@ -360,8 +365,9 @@ body {
|
||||
// Defaults
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
|
||||
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
--font-standard:
|
||||
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
|
||||
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
|
||||
font-family: var(--font-standard);
|
||||
font-size: 16px;
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<template>
|
||||
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
|
||||
<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>
|
||||
<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="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
|
||||
>
|
||||
@@ -18,8 +23,6 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ChevronRightIcon } from "@modrinth/assets";
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
// {
|
||||
@@ -137,3 +140,16 @@ 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">
|
||||
<CrossIcon />
|
||||
<XIcon />
|
||||
</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">
|
||||
<ExitIcon aria-hidden="true" />
|
||||
<XIcon 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')">
|
||||
<CrossIcon aria-hidden="true" /> Reject
|
||||
<XIcon aria-hidden="true" /> Reject
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
@@ -373,9 +373,8 @@ import {
|
||||
UpdatedIcon,
|
||||
CheckIcon,
|
||||
DropdownIcon,
|
||||
XIcon as CrossIcon,
|
||||
EyeOffIcon,
|
||||
ExitIcon,
|
||||
XIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, MarkdownEditor, OverflowMenu, Collapsible } from "@modrinth/ui";
|
||||
|
||||
51
apps/frontend/src/components/ui/NewsletterButton.vue
Normal file
51
apps/frontend/src/components/ui/NewsletterButton.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<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,5 +1,11 @@
|
||||
<template>
|
||||
<div class="vue-notification-group experimental-styles-within">
|
||||
<div
|
||||
class="vue-notification-group experimental-styles-within"
|
||||
:class="{
|
||||
'intercom-present': isIntercomPresent,
|
||||
rightwards: moveNotificationsRight,
|
||||
}"
|
||||
>
|
||||
<transition-group name="notifs">
|
||||
<div
|
||||
v-for="(item, index) in notifications"
|
||||
@@ -79,6 +85,9 @@ import {
|
||||
CopyIcon,
|
||||
} from "@modrinth/assets";
|
||||
const notifications = useNotifications();
|
||||
const { isVisible: moveNotificationsRight } = useNotificationRightwards();
|
||||
|
||||
const isIntercomPresent = ref(false);
|
||||
|
||||
function stopTimer(notif) {
|
||||
clearTimeout(notif.timer);
|
||||
@@ -106,6 +115,27 @@ const createNotifText = (notif) => {
|
||||
return text;
|
||||
};
|
||||
|
||||
function checkIntercomPresence() {
|
||||
isIntercomPresent.value = !!document.querySelector(".intercom-lightweight-app");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkIntercomPresence();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
checkIntercomPresence();
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
function copyToClipboard(notif) {
|
||||
const text = createNotifText(notif);
|
||||
|
||||
@@ -130,6 +160,19 @@ function copyToClipboard(notif) {
|
||||
bottom: 0.75rem;
|
||||
}
|
||||
|
||||
&.intercom-present {
|
||||
bottom: 5rem;
|
||||
}
|
||||
|
||||
&.rightwards {
|
||||
right: unset !important;
|
||||
left: 1.5rem;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
left: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.vue-notification-wrapper {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<div class="table-cell">
|
||||
<BoxIcon />
|
||||
<span>{{
|
||||
$formatProjectType(
|
||||
formatProjectType(
|
||||
$getProjectTypeForDisplay(
|
||||
project.project_types?.[0] ?? "project",
|
||||
project.loaders,
|
||||
@@ -111,6 +111,7 @@
|
||||
<script setup>
|
||||
import { BoxIcon, SettingsIcon, TransferIcon, XIcon } from "@modrinth/assets";
|
||||
import { Button, Modal, Checkbox, CopyCode, Avatar } from "@modrinth/ui";
|
||||
import { formatProjectType } from "@modrinth/utils";
|
||||
|
||||
const modalOpen = ref(null);
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ import {
|
||||
ScaleIcon,
|
||||
DropdownIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { formatProjectType } from "~/plugins/shorthands.js";
|
||||
import { formatProjectType } from "@modrinth/utils";
|
||||
import { acceptTeamInvite, removeTeamMember } from "~/helpers/teams.js";
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
86
apps/frontend/src/components/ui/ShareArticleButtons.vue
Normal file
86
apps/frontend/src/components/ui/ShareArticleButtons.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<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>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Moderation shortcuts" :closable="true">
|
||||
<div>
|
||||
<div class="keybinds-sections">
|
||||
<div class="grid grid-cols-2 gap-x-12 gap-y-3">
|
||||
<div
|
||||
v-for="keybind in keybinds"
|
||||
:key="keybind.id"
|
||||
class="keybind-item flex items-center justify-between gap-4"
|
||||
:class="{
|
||||
'col-span-2': keybinds.length % 2 === 1 && keybinds[keybinds.length - 1] === keybind,
|
||||
}"
|
||||
>
|
||||
<span class="text-sm text-secondary">{{ keybind.description }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<kbd
|
||||
v-for="(key, index) in parseKeybindDisplay(keybind.keybind)"
|
||||
:key="`${keybind.id}-key-${index}`"
|
||||
class="keybind-key"
|
||||
>
|
||||
{{ key }}
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
|
||||
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
|
||||
function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
|
||||
const keybinds = Array.isArray(keybind) ? keybind : [keybind];
|
||||
const normalized = keybinds[0];
|
||||
const def = normalizeKeybind(normalized);
|
||||
|
||||
const keys = [];
|
||||
|
||||
if (def.ctrl || def.meta) {
|
||||
keys.push(isMac() ? "CMD" : "CTRL");
|
||||
}
|
||||
if (def.shift) keys.push("SHIFT");
|
||||
if (def.alt) keys.push("ALT");
|
||||
|
||||
const mainKey = def.key
|
||||
.replace("ArrowLeft", "←")
|
||||
.replace("ArrowRight", "→")
|
||||
.replace("ArrowUp", "↑")
|
||||
.replace("ArrowDown", "↓")
|
||||
.replace("Enter", "↵")
|
||||
.replace("Space", "SPACE")
|
||||
.replace("Escape", "ESC")
|
||||
.toUpperCase();
|
||||
|
||||
keys.push(mainKey);
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function isMac() {
|
||||
return navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
modal.value?.show(event);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.keybind-key {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-contrast);
|
||||
|
||||
+ .keybind-key {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.keybind-item {
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.keybinds-sections {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
|
||||
Modpack permissions ({{ Math.min(modPackData.length, currentIndex + 1) }} /
|
||||
{{ modPackData.length }})
|
||||
</h2>
|
||||
|
||||
<div v-if="!modPackData">Loading data...</div>
|
||||
|
||||
<div v-else-if="modPackData.length === 0">
|
||||
<p>All permissions obtained. You may skip this step!</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!modPackData[currentIndex]">
|
||||
<p>All permission checks complete!</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="modPackData[currentIndex].type === 'unknown'">
|
||||
<p>What is the approval type of {{ modPackData[currentIndex].file_name }}?</p>
|
||||
<div class="input-group">
|
||||
<ButtonStyled
|
||||
v-for="(option, index) in fileApprovalTypes"
|
||||
:key="index"
|
||||
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
|
||||
@click="setStatus(currentIndex, option.id)"
|
||||
>
|
||||
<button>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="modPackData[currentIndex].status !== 'unidentified'" class="flex flex-col gap-1">
|
||||
<label for="proof">
|
||||
<span class="label__title">Proof</span>
|
||||
</label>
|
||||
<input
|
||||
id="proof"
|
||||
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).proof"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter proof of status..."
|
||||
@input="persistAll()"
|
||||
/>
|
||||
<label for="link">
|
||||
<span class="label__title">Link</span>
|
||||
</label>
|
||||
<input
|
||||
id="link"
|
||||
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).url"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter link of project..."
|
||||
@input="persistAll()"
|
||||
/>
|
||||
<label for="title">
|
||||
<span class="label__title">Title</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).title"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter title of project..."
|
||||
@input="persistAll()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="modPackData[currentIndex].type === 'flame'">
|
||||
<p>
|
||||
What is the approval type of {{ modPackData[currentIndex].title }} (<a
|
||||
:href="modPackData[currentIndex].url"
|
||||
target="_blank"
|
||||
class="text-link"
|
||||
>{{ modPackData[currentIndex].url }}</a
|
||||
>)?
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<ButtonStyled
|
||||
v-for="(option, index) in fileApprovalTypes"
|
||||
:key="index"
|
||||
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
|
||||
@click="setStatus(currentIndex, option.id)"
|
||||
>
|
||||
<button>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
['unidentified', 'no', 'with-attribution'].includes(
|
||||
modPackData[currentIndex].status || '',
|
||||
)
|
||||
"
|
||||
>
|
||||
<p v-if="modPackData[currentIndex].status === 'unidentified'">
|
||||
Does this project provide identification and permission for
|
||||
<strong>{{ modPackData[currentIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<p v-else-if="modPackData[currentIndex].status === 'with-attribution'">
|
||||
Does this project provide attribution for
|
||||
<strong>{{ modPackData[currentIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<p v-else>
|
||||
Does this project provide proof of permission for
|
||||
<strong>{{ modPackData[currentIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<ButtonStyled
|
||||
v-for="(option, index) in filePermissionTypes"
|
||||
:key="index"
|
||||
:color="modPackData[currentIndex].approved === option.id ? 'brand' : 'standard'"
|
||||
@click="setApproval(currentIndex, option.id)"
|
||||
>
|
||||
<button>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button :disabled="currentIndex <= 0" @click="goToPrevious">
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
Previous
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="modPackData && currentIndex < modPackData.length" color="blue">
|
||||
<button :disabled="!canGoNext" @click="goToNext">
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
{{ currentIndex + 1 >= modPackData.length ? "Complete" : "Next" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LeftArrowIcon, RightArrowIcon } from "@modrinth/assets";
|
||||
import type {
|
||||
ModerationJudgements,
|
||||
ModerationModpackItem,
|
||||
ModerationModpackResponse,
|
||||
ModerationUnknownModpackItem,
|
||||
ModerationFlameModpackItem,
|
||||
ModerationModpackPermissionApprovalType,
|
||||
ModerationPermissionType,
|
||||
} from "@modrinth/utils";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string;
|
||||
modelValue?: ModerationJudgements;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: [];
|
||||
"update:modelValue": [judgements: ModerationJudgements];
|
||||
}>();
|
||||
|
||||
const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
|
||||
`modpack-permissions-${props.projectId}`,
|
||||
null,
|
||||
{
|
||||
serializer: {
|
||||
read: (v: any) => (v ? JSON.parse(v) : null),
|
||||
write: (v: any) => JSON.stringify(v),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
|
||||
|
||||
const modPackData = ref<ModerationModpackItem[] | null>(null);
|
||||
const currentIndex = ref(0);
|
||||
|
||||
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
|
||||
{
|
||||
id: "yes",
|
||||
name: "Yes",
|
||||
},
|
||||
{
|
||||
id: "with-attribution-and-source",
|
||||
name: "With attribution and source",
|
||||
},
|
||||
{
|
||||
id: "with-attribution",
|
||||
name: "With attribution",
|
||||
},
|
||||
{
|
||||
id: "no",
|
||||
name: "No",
|
||||
},
|
||||
{
|
||||
id: "permanent-no",
|
||||
name: "Permanent no",
|
||||
},
|
||||
{
|
||||
id: "unidentified",
|
||||
name: "Unidentified",
|
||||
},
|
||||
];
|
||||
|
||||
const filePermissionTypes: ModerationPermissionType[] = [
|
||||
{ id: "yes", name: "Yes" },
|
||||
{ id: "no", name: "No" },
|
||||
];
|
||||
|
||||
function persistAll() {
|
||||
persistedModPackData.value = modPackData.value;
|
||||
persistedIndex.value = currentIndex.value;
|
||||
}
|
||||
|
||||
watch(
|
||||
modPackData,
|
||||
(newValue) => {
|
||||
persistedModPackData.value = newValue;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(currentIndex, (newValue) => {
|
||||
persistedIndex.value = newValue;
|
||||
});
|
||||
|
||||
function loadPersistedData(): void {
|
||||
if (persistedModPackData.value) {
|
||||
modPackData.value = persistedModPackData.value;
|
||||
}
|
||||
currentIndex.value = persistedIndex.value;
|
||||
}
|
||||
|
||||
function clearPersistedData(): void {
|
||||
persistedModPackData.value = null;
|
||||
persistedIndex.value = 0;
|
||||
}
|
||||
|
||||
async function fetchModPackData(): Promise<void> {
|
||||
try {
|
||||
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
|
||||
internal: true,
|
||||
})) as ModerationModpackResponse;
|
||||
const sortedData: ModerationModpackItem[] = [
|
||||
...Object.entries(data.unknown_files || {})
|
||||
.map(
|
||||
([sha1, fileName]): ModerationUnknownModpackItem => ({
|
||||
sha1,
|
||||
file_name: fileName,
|
||||
type: "unknown",
|
||||
status: null,
|
||||
approved: null,
|
||||
proof: "",
|
||||
url: "",
|
||||
title: "",
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
|
||||
...Object.entries(data.flame_files || {})
|
||||
.map(
|
||||
([sha1, info]): ModerationFlameModpackItem => ({
|
||||
sha1,
|
||||
file_name: info.file_name,
|
||||
type: "flame",
|
||||
status: null,
|
||||
approved: null,
|
||||
id: info.id,
|
||||
title: info.title || info.file_name,
|
||||
url: info.url || `https://www.curseforge.com/minecraft/mc-mods/${info.id}`,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
|
||||
];
|
||||
|
||||
if (modPackData.value) {
|
||||
const existingMap = new Map(modPackData.value.map((item) => [item.sha1, item]));
|
||||
|
||||
sortedData.forEach((item) => {
|
||||
const existing = existingMap.get(item.sha1);
|
||||
if (existing) {
|
||||
Object.assign(item, {
|
||||
status: existing.status,
|
||||
approved: existing.approved,
|
||||
...(item.type === "unknown" && {
|
||||
proof: (existing as ModerationUnknownModpackItem).proof || "",
|
||||
url: (existing as ModerationUnknownModpackItem).url || "",
|
||||
title: (existing as ModerationUnknownModpackItem).title || "",
|
||||
}),
|
||||
...(item.type === "flame" && {
|
||||
url: (existing as ModerationFlameModpackItem).url || item.url,
|
||||
title: (existing as ModerationFlameModpackItem).title || item.title,
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
modPackData.value = sortedData;
|
||||
persistAll();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch modpack data:", error);
|
||||
modPackData.value = [];
|
||||
persistAll();
|
||||
}
|
||||
}
|
||||
|
||||
function goToPrevious(): void {
|
||||
if (currentIndex.value > 0) {
|
||||
currentIndex.value--;
|
||||
persistAll();
|
||||
}
|
||||
}
|
||||
|
||||
function goToNext(): void {
|
||||
if (modPackData.value && currentIndex.value < modPackData.value.length) {
|
||||
currentIndex.value++;
|
||||
|
||||
if (currentIndex.value >= modPackData.value.length) {
|
||||
const judgements = getJudgements();
|
||||
emit("update:modelValue", judgements);
|
||||
emit("complete");
|
||||
clearPersistedData();
|
||||
} else {
|
||||
persistAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(index: number, status: ModerationModpackPermissionApprovalType["id"]): void {
|
||||
if (modPackData.value && modPackData.value[index]) {
|
||||
modPackData.value[index].status = status;
|
||||
modPackData.value[index].approved = null;
|
||||
persistAll();
|
||||
emit("update:modelValue", getJudgements());
|
||||
}
|
||||
}
|
||||
|
||||
function setApproval(index: number, approved: ModerationPermissionType["id"]): void {
|
||||
if (modPackData.value && modPackData.value[index]) {
|
||||
modPackData.value[index].approved = approved;
|
||||
persistAll();
|
||||
emit("update:modelValue", getJudgements());
|
||||
}
|
||||
}
|
||||
|
||||
const canGoNext = computed(() => {
|
||||
if (!modPackData.value || !modPackData.value[currentIndex.value]) return false;
|
||||
const current = modPackData.value[currentIndex.value];
|
||||
return current.status !== null;
|
||||
});
|
||||
|
||||
function getJudgements(): ModerationJudgements {
|
||||
if (!modPackData.value) return {};
|
||||
|
||||
const judgements: ModerationJudgements = {};
|
||||
|
||||
modPackData.value.forEach((item) => {
|
||||
if (item.type === "flame") {
|
||||
judgements[item.sha1] = {
|
||||
type: "flame",
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
link: item.url,
|
||||
title: item.title,
|
||||
file_name: item.file_name,
|
||||
};
|
||||
} else if (item.type === "unknown") {
|
||||
judgements[item.sha1] = {
|
||||
type: "unknown",
|
||||
status: item.status,
|
||||
proof: item.proof,
|
||||
link: item.url,
|
||||
title: item.title,
|
||||
file_name: item.file_name,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return judgements;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPersistedData();
|
||||
if (!modPackData.value) {
|
||||
fetchModPackData();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.projectId,
|
||||
() => {
|
||||
clearPersistedData();
|
||||
loadPersistedData();
|
||||
if (!modPackData.value) {
|
||||
fetchModPackData();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.modpack-buttons {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
49
apps/frontend/src/components/ui/news/LatestNewsRow.vue
Normal file
49
apps/frontend/src/components/ui/news/LatestNewsRow.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<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>
|
||||
@@ -11,8 +11,8 @@
|
||||
<div class="stacked">
|
||||
<span class="title">{{ report.project.title }}</span>
|
||||
<span>{{
|
||||
$formatProjectType(
|
||||
$getProjectTypeForUrl(report.project.project_type, report.project.loaders),
|
||||
formatProjectType(
|
||||
getProjectTypeForUrl(report.project.project_type, report.project.loaders),
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
@@ -53,8 +53,8 @@
|
||||
<div class="stacked">
|
||||
<span class="title">{{ report.project.title }}</span>
|
||||
<span>{{
|
||||
$formatProjectType(
|
||||
$getProjectTypeForUrl(report.project.project_type, report.project.loaders),
|
||||
formatProjectType(
|
||||
getProjectTypeForUrl(report.project.project_type, report.project.loaders),
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
@@ -105,8 +105,10 @@
|
||||
<script setup>
|
||||
import { ReportIcon, UnknownIcon, VersionIcon } from "@modrinth/assets";
|
||||
import { Avatar, Badge, CopyCode, useRelativeTime } from "@modrinth/ui";
|
||||
import { formatProjectType } from "@modrinth/utils";
|
||||
import { renderHighlightedString } from "~/helpers/highlight.js";
|
||||
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
|
||||
import { getProjectTypeForUrl } from "~/helpers/projects.js";
|
||||
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
|
||||
@@ -170,6 +172,7 @@ const flags = useFeatureFlags();
|
||||
|
||||
.markdown-body {
|
||||
grid-area: body;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.reporter-info {
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
<span
|
||||
v-for="category in categoriesFiltered"
|
||||
:key="category.name"
|
||||
v-html="category.icon + $formatCategory(category.name)"
|
||||
v-html="category.icon + formatCategory(category.name)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatCategory } from "@modrinth/utils";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
categories: {
|
||||
@@ -38,6 +40,7 @@ export default {
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: { formatCategory },
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from "@modrinth/ui";
|
||||
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||
import { ref } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import type { Backup } from "@modrinth/utils";
|
||||
|
||||
const flags = useFeatureFlags();
|
||||
@@ -52,9 +52,10 @@ const failedToCreate = computed(() => props.backup.interrupted);
|
||||
const preparedDownloadStates = ["ready", "done"];
|
||||
const inactiveStates = ["failed", "cancelled"];
|
||||
|
||||
const hasPreparedDownload = computed(() =>
|
||||
preparedDownloadStates.includes(props.backup.task?.file?.state ?? ""),
|
||||
);
|
||||
const hasPreparedDownload = computed(() => {
|
||||
const fileState = props.backup.task?.file?.state ?? "";
|
||||
return preparedDownloadStates.includes(fileState);
|
||||
});
|
||||
|
||||
const creating = computed(() => {
|
||||
const task = props.backup.task?.create;
|
||||
@@ -81,6 +82,10 @@ const restoring = computed(() => {
|
||||
const initiatedPrepare = ref(false);
|
||||
|
||||
const preparingFile = computed(() => {
|
||||
if (hasPreparedDownload.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const task = props.backup.task?.file;
|
||||
return (
|
||||
(!task && initiatedPrepare.value) ||
|
||||
|
||||
@@ -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)]">
|
||||
<UiAvatar :src="modDetails?.icon_url" size="48px" :alt="`${modDetails?.name} Icon`" />
|
||||
<Avatar :src="modDetails?.icon_url" size="48px" :alt="`${modDetails?.name} Icon`" />
|
||||
<span class="truncate text-xl font-extrabold text-contrast">{{ modDetails?.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -31,9 +31,9 @@
|
||||
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
|
||||
@click="
|
||||
versionFilter &&
|
||||
(unlockFilterAccordion.isOpen
|
||||
? unlockFilterAccordion.close()
|
||||
: unlockFilterAccordion.open())
|
||||
(unlockFilterAccordion.isOpen
|
||||
? unlockFilterAccordion.close()
|
||||
: unlockFilterAccordion.open())
|
||||
"
|
||||
>
|
||||
<TagItem
|
||||
@@ -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>
|
||||
<LazyUiCopyCode class="!mt-2 !break-all" :text="versionsError" />
|
||||
<CopyCode class="!mt-2 !break-all" :text="versionsError" />
|
||||
</div>
|
||||
</Admonition>
|
||||
|
||||
@@ -236,7 +236,7 @@ import {
|
||||
GameIcon,
|
||||
ExternalIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Admonition, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { Admonition, Avatar, ButtonStyled, CopyCode, 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";
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
/>
|
||||
<XCircleIcon
|
||||
v-show="
|
||||
item.status === 'error' ||
|
||||
item.status.includes('error') ||
|
||||
item.status === 'cancelled' ||
|
||||
item.status === 'incorrect-type'
|
||||
"
|
||||
@@ -54,9 +54,14 @@
|
||||
<template v-if="item.status === 'completed'">
|
||||
<span>Done</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error'">
|
||||
<template v-else-if="item.status === 'error-file-exists'">
|
||||
<span class="text-red">Failed - File already exists</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error-generic'">
|
||||
<span class="text-red"
|
||||
>Failed - {{ item.error?.message || "An unexpected error occured." }}</span
|
||||
>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'incorrect-type'">
|
||||
<span class="text-red">Failed - Incorrect file type</span>
|
||||
</template>
|
||||
@@ -104,9 +109,17 @@ import { FSModule } from "~/composables/servers/modules/fs.ts";
|
||||
interface UploadItem {
|
||||
file: File;
|
||||
progress: number;
|
||||
status: "pending" | "uploading" | "completed" | "error" | "cancelled" | "incorrect-type";
|
||||
status:
|
||||
| "pending"
|
||||
| "uploading"
|
||||
| "completed"
|
||||
| "error-file-exists"
|
||||
| "error-generic"
|
||||
| "cancelled"
|
||||
| "incorrect-type";
|
||||
size: string;
|
||||
uploader?: any;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -245,8 +258,18 @@ const uploadFile = async (file: File) => {
|
||||
console.error("Error uploading file:", error);
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||
uploadQueue.value[index].status =
|
||||
error instanceof Error && error.message === badFileTypeMsg ? "incorrect-type" : "error";
|
||||
const target = uploadQueue.value[index];
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message === badFileTypeMsg) {
|
||||
target.status = "incorrect-type";
|
||||
} else if (target.progress === 100 && error.message.includes("401")) {
|
||||
target.status = "error-file-exists";
|
||||
} else {
|
||||
target.status = "error-generic";
|
||||
target.error = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
|
||||
@@ -1,124 +1,176 @@
|
||||
<template>
|
||||
<NewModal ref="mrpackModal" header="Uploading mrpack" @hide="onHide" @show="onShow">
|
||||
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
|
||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||
<p
|
||||
v-if="isMrpackModalSecondPhase"
|
||||
:style="{
|
||||
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
|
||||
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
|
||||
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
|
||||
}"
|
||||
<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"
|
||||
>
|
||||
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 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>
|
||||
<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 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>
|
||||
<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>
|
||||
</Transition>
|
||||
|
||||
<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"
|
||||
<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',
|
||||
}"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
{{
|
||||
isMrpackModalSecondPhase
|
||||
? "Erase and install"
|
||||
: loadingServerCheck
|
||||
? "Loading..."
|
||||
: isDangerous
|
||||
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`" />
|
||||
</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"
|
||||
: "Install"
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
:disabled="isLoading"
|
||||
@click="
|
||||
() => {
|
||||
if (isMrpackModalSecondPhase) {
|
||||
isMrpackModalSecondPhase = false;
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
: 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>
|
||||
</Transition>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
|
||||
import { ModrinthServersFetchError } from "@modrinth/utils";
|
||||
import {
|
||||
UploadIcon,
|
||||
RightArrowIcon,
|
||||
XIcon,
|
||||
ServerIcon,
|
||||
ArrowBigRightDashIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { formatBytes, ModrinthServersFetchError } from "@modrinth/utils";
|
||||
import { onMounted, onUnmounted } from "vue";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
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);
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer;
|
||||
@@ -135,6 +187,49 @@ 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);
|
||||
@@ -153,18 +248,46 @@ 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 {
|
||||
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);
|
||||
await promise;
|
||||
|
||||
emit("reinstall", {
|
||||
loader: "mrpack",
|
||||
@@ -176,36 +299,44 @@ 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 reinstall server",
|
||||
title: "Cannot upload and install modpack to server",
|
||||
text: "You are being rate limited. Please try again later.",
|
||||
type: "error",
|
||||
});
|
||||
} else {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Reinstall Failed",
|
||||
text: "An unexpected error occurred while reinstalling. Please try again later.",
|
||||
title: "Modpack upload and install failed",
|
||||
text: "An unexpected error occurred while uploading/installing. 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;
|
||||
};
|
||||
|
||||
const onHide = () => {
|
||||
onShow();
|
||||
uploadProgress.value = 0;
|
||||
uploadedBytes.value = 0;
|
||||
totalBytes.value = 0;
|
||||
currentPhrase.value = "Uploading...";
|
||||
usedPhrases.value.clear();
|
||||
if (phraseInterval) {
|
||||
clearInterval(phraseInterval);
|
||||
phraseInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const show = () => mrpackModal.value?.show();
|
||||
@@ -218,4 +349,14 @@ 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"
|
||||
>
|
||||
<UiAvatar
|
||||
<Avatar
|
||||
: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>
|
||||
<UiCopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
@@ -83,6 +83,7 @@
|
||||
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,9 +50,7 @@
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<component
|
||||
:is="loading ? 'div' : 'NuxtLink'"
|
||||
<nuxt-link
|
||||
: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'"
|
||||
@@ -64,16 +62,17 @@
|
||||
</div>
|
||||
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
|
||||
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
||||
</component>
|
||||
</nuxt-link>
|
||||
</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"));
|
||||
@@ -127,7 +126,7 @@ const metrics = computed(() => {
|
||||
title: "CPU usage",
|
||||
value: "0.00%",
|
||||
max: "100%",
|
||||
icon: CPUIcon,
|
||||
icon: CpuIcon,
|
||||
data: cpuData.value,
|
||||
showGraph: false,
|
||||
warning: null,
|
||||
@@ -158,17 +157,21 @@ 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
|
||||
? formatBytes(stats.value.ram_usage_bytes)
|
||||
: `${ramPercent.toFixed(2)}%`,
|
||||
max: userPreferences.value.ramAsNumber ? formatBytes(stats.value.ram_total_bytes) : "100%",
|
||||
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%",
|
||||
icon: DatabaseIcon,
|
||||
data: ramData.value,
|
||||
showGraph: true,
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { createFormatter, type Formatter } from "@vintl/compact-number";
|
||||
import type { IntlController } from "@vintl/vintl/controller";
|
||||
const formatters = new WeakMap<object, Intl.NumberFormat>();
|
||||
|
||||
const formatters = new WeakMap<IntlController<any>, Formatter>();
|
||||
export function useCompactNumber(truncate = false, fractionDigits = 2, locale?: string) {
|
||||
const context = {};
|
||||
|
||||
export function useCompactNumber(): Formatter {
|
||||
const vintl = useVIntl();
|
||||
let formatter = formatters.get(context);
|
||||
|
||||
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);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(locale, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
formatters.set(context, formatter);
|
||||
}
|
||||
|
||||
return 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;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
projectBackground: false,
|
||||
searchBackground: false,
|
||||
advancedDebugInfo: false,
|
||||
showProjectPageDownloadModalServersPromo: false,
|
||||
showProjectPageCreateServersTooltip: true,
|
||||
showProjectPageQuickServerButton: false,
|
||||
// advancedRendering: true,
|
||||
// externalLinksNewTab: true,
|
||||
// notUsingBlockers: false,
|
||||
|
||||
@@ -102,7 +102,7 @@ export class ModrinthServer {
|
||||
try {
|
||||
const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
|
||||
override: auth,
|
||||
retry: false,
|
||||
retry: 1, // Reduce retries for optional resources
|
||||
});
|
||||
|
||||
if (fileData instanceof Blob && import.meta.client) {
|
||||
@@ -124,8 +124,14 @@ export class ModrinthServer {
|
||||
return dataURL;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServerError && error.statusCode === 404) {
|
||||
if (iconUrl) {
|
||||
if (error instanceof ModrinthServerError) {
|
||||
if (error.statusCode && error.statusCode >= 500) {
|
||||
console.debug("Service unavailable, skipping icon processing");
|
||||
sharedImage.value = undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (error.statusCode === 404 && iconUrl) {
|
||||
try {
|
||||
const response = await fetch(iconUrl);
|
||||
if (!response.ok) throw new Error("Failed to fetch icon");
|
||||
@@ -187,6 +193,44 @@ export class ModrinthServer {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async testNodeReachability(): Promise<boolean> {
|
||||
if (!this.general?.node?.instance) {
|
||||
console.warn("No node instance available for ping test");
|
||||
return false;
|
||||
}
|
||||
|
||||
const wsUrl = `wss://${this.general.node.instance}/pingtest`;
|
||||
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
const socket = new WebSocket(wsUrl);
|
||||
const timeout = setTimeout(() => {
|
||||
socket.close();
|
||||
resolve(false);
|
||||
}, 5000);
|
||||
|
||||
socket.onopen = () => {
|
||||
clearTimeout(timeout);
|
||||
socket.send(performance.now().toString());
|
||||
};
|
||||
|
||||
socket.onmessage = () => {
|
||||
clearTimeout(timeout);
|
||||
socket.close();
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to ping node ${wsUrl}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(
|
||||
modules: ModuleName[] = [],
|
||||
options?: {
|
||||
@@ -200,6 +244,8 @@ export class ModrinthServer {
|
||||
: (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]);
|
||||
|
||||
for (const module of modulesToRefresh) {
|
||||
this.errors[module] = undefined;
|
||||
|
||||
try {
|
||||
switch (module) {
|
||||
case "general": {
|
||||
@@ -250,7 +296,7 @@ export class ModrinthServer {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (error.statusCode === 503) {
|
||||
if (error.statusCode && error.statusCode >= 500) {
|
||||
console.debug(`Temporary ${module} unavailable:`, error.message);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -22,26 +22,49 @@ export class FSModule extends ServerModule {
|
||||
this.opsQueuedForModification = [];
|
||||
}
|
||||
|
||||
private async retryWithAuth<T>(requestFn: () => Promise<T>): Promise<T> {
|
||||
private async retryWithAuth<T>(
|
||||
requestFn: () => Promise<T>,
|
||||
ignoreFailure: boolean = false,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await requestFn();
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServerError && error.statusCode === 401) {
|
||||
console.debug("Auth failed, refreshing JWT and retrying");
|
||||
await this.fetch(); // Refresh auth
|
||||
return await requestFn();
|
||||
}
|
||||
|
||||
const available = await this.server.testNodeReachability();
|
||||
if (!available && !ignoreFailure) {
|
||||
this.server.moduleErrors.general = {
|
||||
error: new ModrinthServerError(
|
||||
"Unable to reach node. FS operation failed and subsequent ping test failed.",
|
||||
500,
|
||||
error as Error,
|
||||
"fs",
|
||||
),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
listDirContents(path: string, page: number, pageSize: number): Promise<DirectoryResponse> {
|
||||
listDirContents(
|
||||
path: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
ignoreFailure: boolean = false,
|
||||
): Promise<DirectoryResponse> {
|
||||
return this.retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
|
||||
override: this.auth,
|
||||
retry: false,
|
||||
});
|
||||
});
|
||||
}, ignoreFailure);
|
||||
}
|
||||
|
||||
createFileOrFolder(path: string, type: "file" | "directory"): Promise<void> {
|
||||
@@ -150,7 +173,7 @@ export class FSModule extends ServerModule {
|
||||
});
|
||||
}
|
||||
|
||||
downloadFile(path: string, raw?: boolean): Promise<any> {
|
||||
downloadFile(path: string, raw: boolean = false, ignoreFailure: boolean = false): Promise<any> {
|
||||
return this.retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const fileData = await useServersFetch(`/download?path=${encodedPath}`, {
|
||||
@@ -161,7 +184,7 @@ export class FSModule extends ServerModule {
|
||||
return raw ? fileData : await fileData.text();
|
||||
}
|
||||
return fileData;
|
||||
});
|
||||
}, ignoreFailure);
|
||||
}
|
||||
|
||||
extractFile(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user