Compare commits
2 Commits
v0.10.1
...
chore/post
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d841484b2c | ||
|
|
c8c60667f6 |
@@ -1,6 +1,6 @@
|
||||
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
|
||||
# Windows has stack overflows when calling from Tauri, so we increase compiler size
|
||||
[target.'cfg(windows)']
|
||||
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
|
||||
rustflags = ["-C", "link-args=/STACK:16777220"]
|
||||
|
||||
[build]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
||||
@@ -14,5 +14,5 @@ max_line_length = 100
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{rs,java,kts}]
|
||||
indent_size = 4
|
||||
[*.rs]
|
||||
indent_size = 4
|
||||
34
.gitattributes
vendored
@@ -1,35 +1 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
# SQLx calculates a checksum of migration scripts at build time to compare
|
||||
# it with the checksum of the applied migration for the same version at
|
||||
# runtime, to know if the migration script has been changed, and thus the
|
||||
# DB schema went out of sync with the code.
|
||||
#
|
||||
# However, such checksum treats the script as a raw byte stream, taking
|
||||
# into account inconsequential differences like different line endings
|
||||
# in different OSes. When combined with Git's EOL conversion and mixed
|
||||
# native and cross-compilation scenarios, this leads to existing
|
||||
# migrations that didn't change having potentially different checksums
|
||||
# according to the environment they were built in, which can break the
|
||||
# migration system when deploying the Modrinth App, rendering it
|
||||
# unusable.
|
||||
#
|
||||
# The gitattribute above ensures that all text files are checked out
|
||||
# with LF line endings, but widely deployed app versions were built
|
||||
# without this attribute set, which left such line endings variable to
|
||||
# the platform. Thus, there is no perfect solution to this problem:
|
||||
# forcing CRLF here would break Linux and macOS users, forcing LF
|
||||
# breaks Windows users, and leaving it unspecified may still lead to
|
||||
# line ending differences when cross-compiling from Linux to Windows
|
||||
# or vice versa, or having Git configured with different line
|
||||
# conversion settings. Moreover, there is no `eol=native` attribute,
|
||||
# and using CI-only scripts to convert line endings would make the
|
||||
# builds differ between CI and most local environments. So, let's pick
|
||||
# the least bad option: let Git handle line endings using its
|
||||
# configuration by leaving it unspecified, which works fine as long as
|
||||
# people don't mess with Git's line ending settings, which is the vast
|
||||
# majority of cases.
|
||||
/packages/app-lib/migrations/20240711194701_init.sql !eol
|
||||
/packages/app-lib/migrations/20240813205023_drop-active-unique.sql !eol
|
||||
/packages/app-lib/migrations/20240930001852_disable-personalized-ads.sql !eol
|
||||
/packages/app-lib/migrations/20241222013857_feature-flags.sql !eol
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/1-app-bug.yml
vendored
@@ -6,7 +6,7 @@ body:
|
||||
attributes:
|
||||
label: Please confirm the following.
|
||||
options:
|
||||
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems
|
||||
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
|
||||
required: true
|
||||
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
|
||||
required: true
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/2-web-bug.yml
vendored
@@ -6,7 +6,7 @@ body:
|
||||
attributes:
|
||||
label: Please confirm the following.
|
||||
options:
|
||||
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems
|
||||
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
|
||||
required: true
|
||||
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
|
||||
required: true
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/3-api-bug.yml
vendored
@@ -6,7 +6,7 @@ body:
|
||||
attributes:
|
||||
label: Please confirm the following.
|
||||
options:
|
||||
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems
|
||||
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
|
||||
required: true
|
||||
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
|
||||
required: true
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/4-feature-request.yml
vendored
@@ -7,7 +7,7 @@ body:
|
||||
attributes:
|
||||
label: Please confirm the following.
|
||||
options:
|
||||
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate feature requests
|
||||
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate feature requests
|
||||
required: true
|
||||
- label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com)
|
||||
required: true
|
||||
|
||||
BIN
.github/assets/api_cover.png
vendored
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 22 KiB |
BIN
.github/assets/app_cover.png
vendored
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 48 KiB |
BIN
.github/assets/monorepo_cover.png
vendored
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 417 KiB |
BIN
.github/assets/web_cover.png
vendored
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 41 KiB |
57
.github/workflows/theseus-release.yml
vendored
@@ -9,18 +9,14 @@ on:
|
||||
- .github/workflows/theseus-release.yml
|
||||
- 'apps/app/**'
|
||||
- 'apps/app-frontend/**'
|
||||
- 'apps/labrinth/src/common/**'
|
||||
- 'apps/labrinth/Cargo.toml'
|
||||
- 'packages/app-lib/**'
|
||||
- 'packages/app-macros/**'
|
||||
- 'packages/assets/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/utils/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sign-windows-binaries:
|
||||
description: Sign Windows binaries
|
||||
type: boolean
|
||||
default: true
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -36,16 +32,16 @@ jobs:
|
||||
|
||||
- name: Rust setup (mac)
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
rustflags: ''
|
||||
target: x86_64-apple-darwin
|
||||
components: rustfmt, clippy
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
|
||||
- name: Rust setup
|
||||
if: "!startsWith(matrix.platform, 'macos')"
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
rustflags: ''
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Setup rust cache
|
||||
uses: actions/cache@v4
|
||||
@@ -76,10 +72,10 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-rust-target-
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
node-version: 20
|
||||
|
||||
- name: Install pnpm via corepack
|
||||
shell: bash
|
||||
@@ -107,21 +103,11 @@ jobs:
|
||||
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
|
||||
|
||||
- name: Install code signing client (Windows only)
|
||||
if: startsWith(matrix.platform, 'windows')
|
||||
run: choco install jsign --ignore-dependencies # GitHub runners come with a global Java installation already
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Disable Windows code signing for non-final release builds
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && !startsWith(github.ref, 'refs/tags/v') && !inputs.sign-windows-binaries }}
|
||||
run: |
|
||||
jq 'del(.bundle.windows.signCommand)' apps/app/tauri-release.conf.json > apps/app/tauri-release.conf.json.new
|
||||
Move-Item -Path apps/app/tauri-release.conf.json.new -Destination apps/app/tauri-release.conf.json -Force
|
||||
|
||||
- name: build app (macos)
|
||||
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
|
||||
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 }}
|
||||
@@ -135,30 +121,15 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: build app (Linux)
|
||||
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
- 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: build app (Windows)
|
||||
run: |
|
||||
[System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream
|
||||
$env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD"
|
||||
$env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
|
||||
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater'
|
||||
Remove-Item -Path signer-client-cert.p12
|
||||
if: startsWith(matrix.platform, 'windows')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
DIGICERT_ONE_SIGNER_API_KEY: ${{ secrets.DIGICERT_ONE_SIGNER_API_KEY }}
|
||||
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64 }}
|
||||
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: upload ${{ matrix.platform }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
98
.github/workflows/turbo-ci.yml
vendored
@@ -2,7 +2,7 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: ['main']
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
merge_group:
|
||||
@@ -10,69 +10,71 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Lint and Test
|
||||
name: Build, Test, and Lint
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
env:
|
||||
# Ensure pnpm output is colored in GitHub Actions logs
|
||||
FORCE_COLOR: 3
|
||||
# Make cargo nextest successfully ignore projects without tests
|
||||
NEXTEST_NO_TESTS: pass
|
||||
|
||||
steps:
|
||||
- name: 📥 Check out code
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: 🧰 Install build dependencies
|
||||
- name: Cache turbo build setup
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: ${{ runner.os }}-turbo-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-turbo-
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: 🧰 Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: 🧰 Setup Node.js
|
||||
- name: Setup Node.JS environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
node-version: 20
|
||||
|
||||
- name: 🧰 Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
- 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
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
rustflags: ''
|
||||
components: clippy, rustfmt
|
||||
cache: false
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: 🧰 Setup nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
|
||||
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
|
||||
# back to a cached cargo install
|
||||
- name: 🧰 Setup cargo-sqlx
|
||||
uses: AlexTMjugador/cache-cargo-install-action@feat/features-support
|
||||
with:
|
||||
tool: sqlx-cli
|
||||
locked: false
|
||||
no-default-features: true
|
||||
features: rustls,postgres
|
||||
|
||||
- name: 💨 Setup Turbo cache
|
||||
uses: rharkor/caching-for-turbo@v1.8
|
||||
|
||||
- name: 🧰 Install dependencies
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: ⚙️ Start services
|
||||
run: docker compose up --wait
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
env:
|
||||
SQLX_OFFLINE: true
|
||||
|
||||
- name: ⚙️ Setup Labrinth environment and database
|
||||
working-directory: apps/labrinth
|
||||
run: |
|
||||
cp .env.local .env
|
||||
sqlx database setup
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
env:
|
||||
SQLX_OFFLINE: true
|
||||
|
||||
- name: 🔍 Lint and test
|
||||
run: pnpm run ci
|
||||
- name: Start docker compose
|
||||
run: docker compose up -d
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
env:
|
||||
SQLX_OFFLINE: true
|
||||
DATABASE_URL: postgresql://labrinth:labrinth@localhost/postgres
|
||||
|
||||
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
@@ -5,4 +5,4 @@
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/code.iml" filepath="$PROJECT_DIR$/.idea/code.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
6
.idea/vcs.xml
generated
@@ -1,11 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CommitMessageInspectionProfile">
|
||||
<profile version="1.0">
|
||||
<inspection_tool class="CommitFormat" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
|
||||
1
.vscode/settings.json
vendored
@@ -2,7 +2,6 @@
|
||||
"prettier.endOfLine": "lf",
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
||||
"editor.detectIndentation": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
|
||||
5432
Cargo.lock
generated
240
Cargo.toml
@@ -1,233 +1,25 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
resolver = '2'
|
||||
members = [
|
||||
"apps/app",
|
||||
"apps/app-playground",
|
||||
"apps/daedalus_client",
|
||||
"apps/labrinth",
|
||||
"packages/app-lib",
|
||||
"packages/ariadne",
|
||||
"packages/daedalus",
|
||||
'./packages/app-lib',
|
||||
'./apps/app-playground',
|
||||
'./apps/app',
|
||||
'./apps/labrinth',
|
||||
'./apps/daedalus_client',
|
||||
'./packages/daedalus',
|
||||
'./packages/ariadne',
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
[workspace.dependencies]
|
||||
actix-cors = "0.7.1"
|
||||
actix-files = "0.6.6"
|
||||
actix-http = "3.11.0"
|
||||
actix-multipart = "0.7.2"
|
||||
actix-rt = "2.10.0"
|
||||
actix-web = "4.11.0"
|
||||
actix-web-prom = "0.10.0"
|
||||
actix-ws = "0.3.0"
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
ariadne = { path = "packages/ariadne" }
|
||||
async_zip = "0.0.17"
|
||||
async-compression = { version = "0.4.25", default-features = false }
|
||||
async-recursion = "1.1.1"
|
||||
async-stripe = { version = "0.41.0", default-features = false, features = [
|
||||
"runtime-tokio-hyper-rustls",
|
||||
] }
|
||||
async-trait = "0.1.88"
|
||||
async-tungstenite = { version = "0.29.1", default-features = false, features = [
|
||||
"futures-03-sink",
|
||||
] }
|
||||
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"
|
||||
chrono = "0.4.41"
|
||||
clap = "4.5.40"
|
||||
clickhouse = "0.13.3"
|
||||
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"
|
||||
dotenv-build = "0.1.1"
|
||||
dotenvy = "0.15.7"
|
||||
dunce = "1.0.5"
|
||||
either = "1.15.0"
|
||||
encoding_rs = "0.8.35"
|
||||
enumset = "1.1.6"
|
||||
flate2 = "1.1.2"
|
||||
fs4 = { version = "0.13.1", default-features = false }
|
||||
futures = { version = "0.3.31", default-features = false }
|
||||
futures-util = "0.3.31"
|
||||
hashlink = "0.10.0"
|
||||
heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
hickory-resolver = "0.25.2"
|
||||
hmac = "0.12.1"
|
||||
hyper-tls = "0.6.0"
|
||||
hyper-util = "0.1.14"
|
||||
iana-time-zone = "0.1.63"
|
||||
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
|
||||
indexmap = "2.9.0"
|
||||
indicatif = "0.17.11"
|
||||
itertools = "0.14.0"
|
||||
jemalloc_pprof = "0.7.0"
|
||||
json-patch = { version = "4.0.0", default-features = false }
|
||||
lettre = { version = "0.11.17", default-features = false, features = [
|
||||
"builder",
|
||||
"hostname",
|
||||
"pool",
|
||||
"ring",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"smtp-transport",
|
||||
] }
|
||||
maxminddb = "0.26.0"
|
||||
meilisearch-sdk = { version = "0.28.0", default-features = false }
|
||||
murmur2 = "0.1.0"
|
||||
native-dialog = "0.9.0"
|
||||
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"
|
||||
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
|
||||
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
|
||||
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
|
||||
regex = "1.11.1"
|
||||
reqwest = { version = "0.12.20", default-features = false }
|
||||
rgb = "0.8.50"
|
||||
rust_decimal = { version = "1.37.2", features = [
|
||||
"serde-with-float",
|
||||
"serde-with-str",
|
||||
] }
|
||||
rust_iso3166 = "0.1.14"
|
||||
rust-s3 = { version = "0.35.1", default-features = false, features = [
|
||||
"fail-on-err",
|
||||
"tags",
|
||||
"tokio-rustls-tls",
|
||||
] }
|
||||
rusty-money = "0.4.1"
|
||||
sentry = { version = "0.41.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
"debug-images",
|
||||
"panic",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
] }
|
||||
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.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"] }
|
||||
sha2 = "0.10.9"
|
||||
spdx = "0.10.8"
|
||||
sqlx = { version = "0.8.6", default-features = false }
|
||||
sysinfo = { version = "0.35.2", default-features = false }
|
||||
tar = "0.4.44"
|
||||
tauri = "2.6.1"
|
||||
tauri-build = "2.3.0"
|
||||
tauri-plugin-deep-link = "2.4.0"
|
||||
tauri-plugin-dialog = "2.3.0"
|
||||
tauri-plugin-http = "2.5.0"
|
||||
tauri-plugin-opener = "2.4.0"
|
||||
tauri-plugin-os = "2.3.0"
|
||||
tauri-plugin-single-instance = "2.3.0"
|
||||
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
"zip",
|
||||
] }
|
||||
tauri-plugin-window-state = "2.3.0"
|
||||
tempfile = "3.20.0"
|
||||
theseus = { path = "packages/app-lib" }
|
||||
thiserror = "2.0.12"
|
||||
tikv-jemalloc-ctl = "0.6.0"
|
||||
tikv-jemallocator = "0.6.0"
|
||||
tokio = "1.45.1"
|
||||
tokio-stream = "0.1.17"
|
||||
tokio-util = "0.7.15"
|
||||
totp-rs = "5.7.0"
|
||||
tracing = "0.1.41"
|
||||
tracing-actix-web = "0.7.18"
|
||||
tracing-error = "0.2.1"
|
||||
tracing-subscriber = "0.3.19"
|
||||
url = "2.5.4"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = "1.17.0"
|
||||
validator = "0.20.0"
|
||||
webp = { version = "0.3.0", default-features = false }
|
||||
whoami = "1.6.0"
|
||||
winreg = "0.55.0"
|
||||
woothee = "0.13.0"
|
||||
yaserde = "0.12.0"
|
||||
zip = { version = "4.2.0", default-features = false, features = [
|
||||
"bzip2",
|
||||
"deflate",
|
||||
"deflate64",
|
||||
"zstd",
|
||||
] }
|
||||
zxcvbn = "3.1.0"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
bool_to_int_with_if = "warn"
|
||||
borrow_as_ptr = "warn"
|
||||
cfg_not_test = "warn"
|
||||
clear_with_drain = "warn"
|
||||
cloned_instead_of_copied = "warn"
|
||||
collection_is_never_read = "warn"
|
||||
dbg_macro = "warn"
|
||||
default_trait_access = "warn"
|
||||
explicit_iter_loop = "warn"
|
||||
filter_map_next = "warn"
|
||||
flat_map_option = "warn"
|
||||
format_push_string = "warn"
|
||||
get_unwrap = "warn"
|
||||
large_include_file = "warn"
|
||||
large_stack_arrays = "warn"
|
||||
manual_assert = "warn"
|
||||
manual_instant_elapsed = "warn"
|
||||
manual_is_variant_and = "warn"
|
||||
manual_let_else = "warn"
|
||||
map_unwrap_or = "warn"
|
||||
match_bool = "warn"
|
||||
needless_collect = "warn"
|
||||
negative_feature_names = "warn"
|
||||
non_std_lazy_statics = "warn"
|
||||
pathbuf_init_then_push = "warn"
|
||||
read_zero_byte_vec = "warn"
|
||||
redundant_clone = "warn"
|
||||
redundant_feature_names = "warn"
|
||||
redundant_type_annotations = "warn"
|
||||
todo = "warn"
|
||||
unnested_or_patterns = "warn"
|
||||
wildcard_dependencies = "warn"
|
||||
|
||||
[workspace.lints.rust]
|
||||
# Turn warnings into errors by default
|
||||
warnings = "deny"
|
||||
|
||||
[patch.crates-io]
|
||||
wry = { git = "https://github.com/modrinth/wry", rev = "21db186" }
|
||||
|
||||
# Optimize for speed and reduce size on release builds
|
||||
[profile.release]
|
||||
opt-level = "s" # Optimize for binary size
|
||||
strip = true # Remove debug symbols
|
||||
lto = true # Enables link to optimizations
|
||||
panic = "abort" # Strip expensive panic clean-up logic
|
||||
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
|
||||
panic = "abort" # Strip expensive panic clean-up logic
|
||||
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
|
||||
lto = true # Enables link to optimizations
|
||||
opt-level = "s" # Optimize for binary size
|
||||
strip = true # Remove debug symbols
|
||||
|
||||
[profile.dev.package.sqlx-macros]
|
||||
opt-level = 3
|
||||
|
||||
[patch.crates-io]
|
||||
wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" }
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
**/dist
|
||||
*.gltf
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@modrinth/app-frontend",
|
||||
"private": true,
|
||||
"version": "0.10.1",
|
||||
"version": "0.9.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,31 +9,26 @@
|
||||
"tsc:check": "vue-tsc --noEmit",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write .",
|
||||
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
||||
"test": "vue-tsc --noEmit"
|
||||
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace"
|
||||
},
|
||||
"dependencies": {
|
||||
"@geometrically/minecraft-motd-parser": "^1.1.4",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@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",
|
||||
"@geometrically/minecraft-motd-parser": "^1.1.4",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"@tauri-apps/plugin-os": "^2.2.0",
|
||||
"@tauri-apps/plugin-opener": "^2.2.1",
|
||||
"@tauri-apps/plugin-updater": "^2.3.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.2.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",
|
||||
@@ -44,12 +39,11 @@
|
||||
"@eslint/compat": "^1.1.1",
|
||||
"@formatjs/cli": "^6.2.12",
|
||||
"@nuxt/eslint-config": "^0.5.6",
|
||||
"@taijased/vue-render-tracker": "^1.0.7",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-config-custom": "workspace:*",
|
||||
"eslint-plugin-turbo": "^2.5.4",
|
||||
"eslint-plugin-turbo": "^2.1.1",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "^3.2.5",
|
||||
"sass": "^1.74.1",
|
||||
@@ -57,7 +51,8 @@
|
||||
"tsconfig": "workspace:*",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.6",
|
||||
"vue-tsc": "^2.1.6"
|
||||
"vue-tsc": "^2.1.6",
|
||||
"@taijased/vue-render-tracker": "^1.0.7"
|
||||
},
|
||||
"packageManager": "pnpm@9.4.0",
|
||||
"web-types": "../../web-types.json"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowBigUpDashIcon,
|
||||
ChangeSkinIcon,
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
HomeIcon,
|
||||
@@ -19,21 +18,13 @@ import {
|
||||
SettingsIcon,
|
||||
WorldIcon,
|
||||
XIcon,
|
||||
NewspaperIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonStyled,
|
||||
Notifications,
|
||||
OverflowMenu,
|
||||
NewsArticleCard,
|
||||
} from '@modrinth/ui'
|
||||
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
|
||||
import { useLoading, useTheming } from '@/store/state'
|
||||
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
||||
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { get } from '@/helpers/settings'
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
@@ -64,13 +55,12 @@ 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 themeStore = useTheming()
|
||||
|
||||
@@ -178,7 +168,6 @@ async function setupApp() {
|
||||
'criticalAnnouncements',
|
||||
true,
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((res) => {
|
||||
if (res && res.header && res.body) {
|
||||
criticalErrorMessage.value = res
|
||||
@@ -190,35 +179,15 @@ async function setupApp() {
|
||||
)
|
||||
})
|
||||
|
||||
useFetch(`https://modrinth.com/news/feed/articles.json`, 'news', true)
|
||||
.then((response) => response.json())
|
||||
.then((res) => {
|
||||
if (res && res.articles) {
|
||||
// Format expected by NewsArticleCard component.
|
||||
news.value = res.articles
|
||||
.map((article) => ({
|
||||
...article,
|
||||
path: article.link,
|
||||
thumbnail: article.thumbnail,
|
||||
title: article.title,
|
||||
summary: article.summary,
|
||||
date: article.date,
|
||||
}))
|
||||
.slice(0, 4)
|
||||
}
|
||||
})
|
||||
useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => {
|
||||
if (res && res.articles) {
|
||||
news.value = res.articles
|
||||
}
|
||||
})
|
||||
|
||||
get_opening_command().then(handleCommand)
|
||||
checkUpdates()
|
||||
fetchCredentials()
|
||||
|
||||
try {
|
||||
const skins = (await get_available_skins()) ?? []
|
||||
const capes = (await get_available_capes()) ?? []
|
||||
generateSkinPreviews(skins, capes)
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate skin previews in app setup.', error)
|
||||
}
|
||||
}
|
||||
|
||||
const stateFailed = ref(false)
|
||||
@@ -326,7 +295,6 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const accounts = ref(null)
|
||||
provide('accountsCard', accounts)
|
||||
|
||||
command_listener(handleCommand)
|
||||
async function handleCommand(e) {
|
||||
@@ -422,9 +390,6 @@ function handleAuxClick(e) {
|
||||
>
|
||||
<CompassIcon />
|
||||
</NavButton>
|
||||
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
|
||||
<ChangeSkinIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="'Library'"
|
||||
to="/library"
|
||||
@@ -605,20 +570,34 @@ function handleAuxClick(e) {
|
||||
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
|
||||
</suspense>
|
||||
</div>
|
||||
<div v-if="news && news.length > 0" class="pt-4 flex flex-col items-center">
|
||||
<h3 class="px-4 text-lg m-0 text-left w-full">News</h3>
|
||||
<div class="px-4 pt-2 space-y-4 flex flex-col items-center w-full">
|
||||
<NewsArticleCard
|
||||
v-for="(item, index) in news"
|
||||
:key="`news-${index}`"
|
||||
:article="item"
|
||||
<div v-if="news && news.length > 0" class="pt-4 flex flex-col">
|
||||
<h3 class="px-4 text-lg m-0">News</h3>
|
||||
<template v-for="(item, index) in news" :key="`news-${index}`">
|
||||
<a
|
||||
:class="`flex flex-col outline-offset-[-4px] hover:bg-[--brand-gradient-border] focus:bg-[--brand-gradient-border] px-4 transition-colors ${index === 0 ? 'pt-2 pb-4' : 'py-4'}`"
|
||||
:href="item.link"
|
||||
target="_blank"
|
||||
rel="external"
|
||||
>
|
||||
<img
|
||||
:src="item.thumbnail"
|
||||
alt="News thumbnail"
|
||||
aria-hidden="true"
|
||||
class="w-full aspect-[3/1] object-cover rounded-2xl border-[1px] border-solid border-[--brand-gradient-border]"
|
||||
/>
|
||||
<h4 class="mt-2 mb-0 text-sm leading-none text-contrast font-semibold">
|
||||
{{ item.title }}
|
||||
</h4>
|
||||
<p class="my-1 text-sm text-secondary leading-tight">{{ item.summary }}</p>
|
||||
<p class="text-right text-sm text-secondary opacity-60 leading-tight m-0">
|
||||
{{ dayjs(item.date).fromNow() }}
|
||||
</p>
|
||||
</a>
|
||||
<hr
|
||||
v-if="index !== news.length - 1"
|
||||
class="h-px my-[-2px] mx-4 border-0 m-0 bg-[--brand-gradient-border]"
|
||||
/>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<a href="https://modrinth.com/news" target="_blank" class="my-4">
|
||||
<NewspaperIcon /> View all news
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
BIN
apps/app-frontend/src/assets/external/gdlauncher.png
vendored
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 937 KiB |
@@ -1 +0,0 @@
|
||||
{"asset":{"version":"2.0","generator":"Blockbench 4.12.4 glTF exporter"},"scenes":[{"nodes":[1],"name":"blockbench_export"}],"scene":0,"nodes":[{"rotation":[0,0,0.19509032201612825,0.9807852804032304],"translation":[0.15625,1,0],"name":"Cape","mesh":0},{"children":[0]}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":288,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":576,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":768,"byteLength":72,"target":34963}],"buffers":[{"byteLength":840,"uri":"data:application/octet-stream;base64,AAAAPQAAAAAAAKA+AAAAPQAAAAAAAKC+AAAAPQAAgL8AAKA+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAvQAAgL8AAKC+AAAAvQAAgL8AAKA+AAAAvQAAAAAAAKC+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAvQAAgL8AAKC+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKC+AAAAPQAAgL8AAKC+AAAAvQAAgL8AAKC+AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AACAPAAAgD0AADA+AACAPQAAgDwAAAg/AAAwPgAACD8AAEA+AACAPQAAsD4AAIA9AABAPgAACD8AALA+AAAIPwAAgDwAAAA9AACAPAAAgD0AADA+AAAAPQAAMD4AAIA9AAAwPgAAAD0AAKg+AAAAPQAAMD4AAAAAAACoPgAAAAAAAEA+AACAPQAAMD4AAIA9AABAPgAACD8AADA+AAAIPwAAAAAAAIA9AACAPAAAgD0AAAAAAAAIPwAAgDwAAAg/AAACAAEAAgADAAEABAAGAAUABgAHAAUACAAKAAkACgALAAkADAAOAA0ADgAPAA0AEAASABEAEgATABEAFAAWABUAFgAXABUA"}],"accessors":[{"bufferView":0,"componentType":5126,"count":24,"max":[0.03125,0,0.3125],"min":[-0.03125,-1,-0.3125],"type":"VEC3"},{"bufferView":1,"componentType":5126,"count":24,"max":[1,1,1],"min":[-1,-1,-1],"type":"VEC3"},{"bufferView":2,"componentType":5126,"count":24,"max":[0.34375,0.53125],"min":[0,0],"type":"VEC2"},{"bufferView":3,"componentType":5123,"count":36,"max":[23],"min":[0],"type":"SCALAR"}],"materials":[{"pbrMetallicRoughness":{"metallicFactor":0,"roughnessFactor":1,"baseColorTexture":{"index":0}},"alphaMode":"MASK","alphaCutoff":0.05,"doubleSided":true}],"textures":[{"sampler":0,"source":0,"name":"cape.png"}],"samplers":[{"magFilter":9728,"minFilter":9728,"wrapS":33071,"wrapT":33071}],"images":[{"mimeType":"image/png","uri":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAAAAXNSR0IArs4c6QAABCRJREFUaEPtlktoE1EUhm/sIzFpYsBgHyn2oRZt0C5ESxWFYutKNyKIWBBR6kpBd3Ur2J0FRdCiuKmIIG4qLnzgQqTUXSlVqdoXTU0lljRpYtOHI/8dznBnOm1mphMwJWeTzJ0795zznf/cex2MMXam2S/ht38siJ8V1lgd5mOBARerc3rZnmK37rwvCyk2nE6wezMRh+4EncH9B1ql+fkU64rPsaBz5bqh4T7Daxn1Kc5zVNeEePJIci0Aq71bzenY6JChwAnA0OBHQ/OtJLnWNwqAy61R1tO3k891ueRKoDKwtqbv7MGbgCnfRgHQoqG9hyX4hc/yiho+/HNqVPFtdj2jwTpQgd/RKT7/0ZUku/o4qAJw50KYXbzr4e+3BioYzc3kwGzAUCLWFw2+UBiCb3bNTDHiPQeAP3CGNmg/6VcSBpDu3hhvDQpOCwABwrQKsRIsqYAChy/EQAXwlPiZ3a3igNPkXIyTjr8o5L7PPdzGf59c+sV/faeWeIIIAHPJ8M3kc7l1K09LKghWAIgqoPZ7djPFTlxb4D6yAgBOQfntrUVWVeRk44tplXJorOVGkVIJTBCTpw9ECFYBIEkyMfmsAXh3u1qi5MlxbbGX/x1ZSCjBAAwgfPr6h49R5UVavk0FXC2wju5p07s6iqEF0PtqSlEf+bKzDRwdgaDU7AkoySJ5Oo/D6ZRq/H0yyuJ/lxkSheG/FgCNm7kL0BoEAG32sqtY1YIHd2/mGzTMVgD3y2slqjgW9xY6ma9AThAARIMiBtOpjADwTWc0bEkB+FZsSTxTW0KBsGPXx0yvrUpEeHAAQIM7wBJLcu8DwHaP/H8i6VSND6SiSjBlRW4WWUypVIBbIgzjVgB0tpdKqLReS0KVPTMTfH0ra2cEgAmAAEd+l1z52LybqwBQYAQAyZPh6gtDW4jjuC4fHx8wXSm0JDbeI95SRYHiFZlUaWVtPQiKAugl5E8ASAUYiy8vc0C474uGasPE5PHc4g0wK/f4obom6UNimol7kTZwQLANwOuqBokqDEeQf4lfvvnNxZJcBTBAGZplWQcQ3tcgwY9oWlXinRW4ugoAgNAWWe6ocn1QvgyRTUb4RZFVljlY/3hSBYCqb6cCZo8ekuATVRZPI/gQW8FWAI1VHganVgDQUajdA6y2gAgAcZFBjTBSpG0AcAqc3VVmCMDTbxGWZvIRCaMNkJ7pFMCzVQD4liCQ8kRFUlvaBkCvL+wYw2ZmNUgCgLajFhRPJlv3ADuS1VtjPQCk823S574ffN/x1dQqy8dHR5SN2Spcbaymz2mjwNYDAD4IQn3TDpVLQIAqNjwAANRrAdoI/3sAOF7Xe1khCNQGVH1bL0JGJb1R52VtD8gVYHkAuVKpbMWZV0C2yObKunkF5EqlshVnXgHZIpsr6/4DlbxcPydnT74AAAAASUVORK5CYII="}],"meshes":[{"primitives":[{"mode":4,"attributes":{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2},"indices":3,"material":0}]}]}
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -7,7 +7,7 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/regular.otf') format('opentype');
|
||||
src: url('https://cdn.modrinth.com/fonts/minecraft/regular.otf') format('opentype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@@ -15,7 +15,7 @@
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/italic.otf') format('opentype');
|
||||
src: url('https://cdn.modrinth.com/fonts/minecraft/italic.otf') format('opentype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@@ -23,7 +23,7 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 600;
|
||||
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold.otf') format('opentype');
|
||||
src: url('https://cdn.modrinth.com/fonts/minecraft/bold.otf') format('opentype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@@ -31,7 +31,7 @@
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
font-weight: 600;
|
||||
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype');
|
||||
src: url('https://cdn.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype');
|
||||
}
|
||||
|
||||
.font-minecraft {
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
<Avatar
|
||||
size="36px"
|
||||
:src="
|
||||
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
selectedAccount
|
||||
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
|
||||
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
"
|
||||
/>
|
||||
<div class="flex flex-col w-full">
|
||||
<span>{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}</span>
|
||||
<span>{{ selectedAccount ? selectedAccount.username : 'Select account' }}</span>
|
||||
<span class="text-secondary text-xs">Minecraft account</span>
|
||||
</div>
|
||||
<DropdownIcon class="w-5 h-5 shrink-0" />
|
||||
@@ -26,40 +28,28 @@
|
||||
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
|
||||
>
|
||||
<div v-if="selectedAccount" class="selected account">
|
||||
<Avatar size="xs" :src="avatarUrl" />
|
||||
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.id}/128`" />
|
||||
<div>
|
||||
<h4>{{ selectedAccount.profile.name }}</h4>
|
||||
<h4>{{ selectedAccount.username }}</h4>
|
||||
<p>Selected</p>
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="'Log out'"
|
||||
icon-only
|
||||
color="raised"
|
||||
@click="logout(selectedAccount.profile.id)"
|
||||
>
|
||||
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.id)">
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="logged-out account">
|
||||
<h4>Not signed in</h4>
|
||||
<Button
|
||||
v-tooltip="'Log in'"
|
||||
:disabled="loginDisabled"
|
||||
icon-only
|
||||
color="primary"
|
||||
@click="login()"
|
||||
>
|
||||
<LogInIcon v-if="!loginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
<Button v-tooltip="'Log in'" icon-only color="primary" @click="login()">
|
||||
<LogInIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="displayAccounts.length > 0" class="account-group">
|
||||
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
|
||||
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
|
||||
<Button class="option account" @click="setAccount(account)">
|
||||
<Avatar :src="getAccountAvatarUrl(account)" class="icon" />
|
||||
<p>{{ account.profile.name }}</p>
|
||||
<Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
|
||||
<p>{{ account.username }}</p>
|
||||
</Button>
|
||||
<Button v-tooltip="'Log out'" icon-only @click="logout(account.profile.id)">
|
||||
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -73,7 +63,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon } from '@modrinth/assets'
|
||||
import { Avatar, Button, Card } from '@modrinth/ui'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||
import {
|
||||
@@ -87,8 +77,6 @@ import { handleError } from '@/store/state.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { get_available_skins } from '@/helpers/skins'
|
||||
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
|
||||
defineProps({
|
||||
mode: {
|
||||
@@ -101,86 +89,32 @@ defineProps({
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const accounts = ref({})
|
||||
const loginDisabled = ref(false)
|
||||
const defaultUser = ref()
|
||||
const equippedSkin = ref(null)
|
||||
const headUrlCache = ref(new Map())
|
||||
|
||||
async function refreshValues() {
|
||||
defaultUser.value = await get_default_user().catch(handleError)
|
||||
accounts.value = await users().catch(handleError)
|
||||
|
||||
try {
|
||||
const skins = await get_available_skins()
|
||||
equippedSkin.value = skins.find((skin) => skin.is_equipped)
|
||||
|
||||
if (equippedSkin.value) {
|
||||
try {
|
||||
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
|
||||
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
|
||||
} catch (error) {
|
||||
console.warn('Failed to get head render for equipped skin:', error)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
equippedSkin.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function setLoginDisabled(value) {
|
||||
loginDisabled.value = value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refreshValues,
|
||||
setLoginDisabled,
|
||||
loginDisabled,
|
||||
})
|
||||
await refreshValues()
|
||||
|
||||
const displayAccounts = computed(() =>
|
||||
accounts.value.filter((account) => defaultUser.value !== account.profile.id),
|
||||
accounts.value.filter((account) => defaultUser.value !== account.id),
|
||||
)
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
if (equippedSkin.value?.texture_key) {
|
||||
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
|
||||
if (cachedUrl) {
|
||||
return cachedUrl
|
||||
}
|
||||
return `https://mc-heads.net/avatar/${equippedSkin.value.texture_key}/128`
|
||||
}
|
||||
if (selectedAccount.value?.profile?.id) {
|
||||
return `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128`
|
||||
}
|
||||
return 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
})
|
||||
|
||||
function getAccountAvatarUrl(account) {
|
||||
if (
|
||||
account.profile.id === selectedAccount.value?.profile?.id &&
|
||||
equippedSkin.value?.texture_key
|
||||
) {
|
||||
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
|
||||
if (cachedUrl) {
|
||||
return cachedUrl
|
||||
}
|
||||
}
|
||||
return `https://mc-heads.net/avatar/${account.profile.id}/128`
|
||||
}
|
||||
|
||||
const selectedAccount = computed(() =>
|
||||
accounts.value.find((account) => account.profile.id === defaultUser.value),
|
||||
accounts.value.find((account) => account.id === defaultUser.value),
|
||||
)
|
||||
|
||||
async function setAccount(account) {
|
||||
defaultUser.value = account.profile.id
|
||||
await set_default_user(account.profile.id).catch(handleError)
|
||||
defaultUser.value = account.id
|
||||
await set_default_user(account.id).catch(handleError)
|
||||
emit('change')
|
||||
}
|
||||
|
||||
async function login() {
|
||||
loginDisabled.value = true
|
||||
const loggedIn = await login_flow().catch(handleSevereError)
|
||||
|
||||
if (loggedIn) {
|
||||
@@ -189,7 +123,6 @@ async function login() {
|
||||
}
|
||||
|
||||
trackEvent('AccountLogIn')
|
||||
loginDisabled.value = false
|
||||
}
|
||||
|
||||
const logout = async (id) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ref, computed } from 'vue'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { cancel_directory_change } from '@/helpers/settings.ts'
|
||||
import { cancel_directory_change } from '@/helpers/settings.js'
|
||||
import { install } from '@/helpers/profile.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
@@ -92,7 +92,7 @@ async function loginMinecraft() {
|
||||
const loggedIn = await login_flow()
|
||||
|
||||
if (loggedIn) {
|
||||
await set_default_user(loggedIn.profile.id).catch(handleError)
|
||||
await set_default_user(loggedIn.id).catch(handleError)
|
||||
}
|
||||
|
||||
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
||||
@@ -219,8 +219,8 @@ async function copyToClipboard(text) {
|
||||
<template v-else-if="metadata.notEnoughSpace">
|
||||
<h3>Not enough space</h3>
|
||||
<p>
|
||||
It looks like there is not enough space on the disk containing the directory you
|
||||
selected. Please free up some space and try again or cancel the directory change.
|
||||
It looks like there is not enough space on the disk containing the dirctory you
|
||||
selected Please free up some space and try again or cancel the directory change.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled, useRelativeTime } from '@modrinth/ui'
|
||||
import { Avatar, ButtonStyled } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { finish_install, kill, run } from '@/helpers/profile'
|
||||
import { get_by_profile_path } from '@/helpers/process'
|
||||
@@ -19,8 +19,10 @@ import { showProfileInFolder } from '@/helpers/utils.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { formatCategory } from '@modrinth/utils'
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
@@ -171,12 +173,7 @@ onUnmounted(() => unlisten())
|
||||
</div>
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
|
||||
<TimerIcon />
|
||||
<span class="text-sm">
|
||||
<template v-if="instance.last_played">
|
||||
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
</span>
|
||||
<span class="text-sm"> Played {{ dayjs(instance.last_played).fromNow() }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -239,8 +236,8 @@ onUnmounted(() => unlisten())
|
||||
</p>
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
|
||||
<GameIcon class="shrink-0" />
|
||||
<span class="text-sm capitalize">
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
<span class="text-sm">
|
||||
{{ formatCategory(instance.loader) }} {{ instance.game_version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ModalWrapper ref="modal" header="Creating an instance">
|
||||
<ModalWrapper ref="modal" header="Create instance">
|
||||
<div class="modal-header">
|
||||
<Chips v-model="creationType" :items="['custom', 'from file', 'import from launcher']" />
|
||||
</div>
|
||||
|
||||
@@ -127,7 +127,7 @@ async function handleJavaFileInput() {
|
||||
const filePath = await open()
|
||||
|
||||
if (filePath) {
|
||||
let result = await get_jre(filePath.path ?? filePath).catch(handleError)
|
||||
let result = await get_jre(filePath.path ?? filePath)
|
||||
if (!result) {
|
||||
result = {
|
||||
path: filePath.path ?? filePath,
|
||||
|
||||
@@ -32,33 +32,8 @@ function updateAdPosition() {
|
||||
|
||||
<template>
|
||||
<div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
|
||||
<a
|
||||
href="https://modrinth.gg?from=app-placeholder"
|
||||
target="_blank"
|
||||
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
|
||||
>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
class="hidden light-image rounded-[inherit]"
|
||||
/>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
class="dark-image rounded-[inherit]"
|
||||
/>
|
||||
</a>
|
||||
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
|
||||
<p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.light,
|
||||
.light-mode {
|
||||
.dark-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.light-image {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,7 +30,7 @@ const getInstances = async () => {
|
||||
|
||||
return dateB - dateA
|
||||
})
|
||||
.slice(0, 3)
|
||||
.slice(0, 4)
|
||||
}
|
||||
|
||||
await getInstances()
|
||||
|
||||
@@ -124,11 +124,8 @@ import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { ref, computed } from 'vue'
|
||||
import { install as installVersion } from '@/store/install.js'
|
||||
import { useRouter } from 'vue-router'
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
backgroundImage: {
|
||||
type: String,
|
||||
@@ -171,9 +168,6 @@ async function install() {
|
||||
installing.value = false
|
||||
emit('install', props.project.project_id ?? props.project.id)
|
||||
},
|
||||
(profile) => {
|
||||
router.push(`/instance/${profile}`)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Avatar, ButtonStyled, OverflowMenu, useRelativeTime } from '@modrinth/ui'
|
||||
import { Avatar, ButtonStyled, OverflowMenu } from '@modrinth/ui'
|
||||
import {
|
||||
UserPlusIcon,
|
||||
MoreVerticalIcon,
|
||||
@@ -18,8 +18,6 @@ import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const props = defineProps<{
|
||||
credentials: unknown | null
|
||||
signIn: () => void
|
||||
@@ -207,9 +205,7 @@ onUnmounted(() => {
|
||||
You sent <span class="font-bold">{{ friend.username }}</span> a friend request
|
||||
</template>
|
||||
</p>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ formatRelativeTime(friend.created.toISOString()) }}
|
||||
</p>
|
||||
<p class="m-0 text-sm text-secondary">{{ friend.created.fromNow() }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<template v-if="friend.id === userCredentials.user_id">
|
||||
|
||||
@@ -13,17 +13,15 @@ const confirmModal = ref(null)
|
||||
const installing = ref(false)
|
||||
|
||||
const onInstall = ref(() => {})
|
||||
const onCreateInstance = ref(() => {})
|
||||
|
||||
defineExpose({
|
||||
show: (projectVal, versionIdVal, callback, createInstanceCallback) => {
|
||||
show: (projectVal, versionIdVal, callback) => {
|
||||
project.value = projectVal
|
||||
versionId.value = versionIdVal
|
||||
installing.value = false
|
||||
confirmModal.value.show()
|
||||
|
||||
onInstall.value = callback
|
||||
onCreateInstance.value = createInstanceCallback
|
||||
|
||||
trackEvent('PackInstallStart')
|
||||
},
|
||||
@@ -38,7 +36,6 @@ async function install() {
|
||||
versionId.value,
|
||||
project.value.title,
|
||||
project.value.icon_url,
|
||||
onCreateInstance.value,
|
||||
).catch(handleError)
|
||||
trackEvent('PackInstall', {
|
||||
id: project.value.id,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Checkbox } from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { get } from '@/helpers/settings'
|
||||
import { edit } from '@/helpers/profile'
|
||||
import type { InstanceSettingsTabProps, AppSettings, Hooks } from '../../../helpers/types'
|
||||
|
||||
@@ -25,8 +25,9 @@ const editProfileObject = computed(() => {
|
||||
hooks?: Hooks
|
||||
} = {}
|
||||
|
||||
// When hooks are not overridden per-instance, we want to clear them
|
||||
editProfile.hooks = overrideHooks.value ? hooks.value : {}
|
||||
if (overrideHooks.value) {
|
||||
editProfile.hooks = hooks.value
|
||||
}
|
||||
|
||||
return editProfile
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { get } from '@/helpers/settings'
|
||||
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Checkbox, Toggle } from '@modrinth/ui'
|
||||
import { computed, ref, type Ref, watch } from 'vue'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { get } from '@/helpers/settings'
|
||||
import { edit } from '@/helpers/profile'
|
||||
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ShieldIcon,
|
||||
SettingsIcon,
|
||||
GaugeIcon,
|
||||
PaintbrushIcon,
|
||||
PaintBrushIcon,
|
||||
GameIcon,
|
||||
CoffeeIcon,
|
||||
} from '@modrinth/assets'
|
||||
@@ -22,7 +22,7 @@ import { version as getOsVersion, platform as getOsPlatform } from '@tauri-apps/
|
||||
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'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -41,7 +41,7 @@ const tabs = [
|
||||
id: 'app.settings.tabs.appearance',
|
||||
defaultMessage: 'Appearance',
|
||||
}),
|
||||
icon: PaintbrushIcon,
|
||||
icon: PaintBrushIcon,
|
||||
content: AppearanceSettings,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { ConfirmModal } from '@modrinth/ui'
|
||||
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
|
||||
import { useTheming } from '@/store/theme.ts'
|
||||
import { useTheming } from '@/store/theme.js'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { NewModal as Modal } from '@modrinth/ui'
|
||||
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
|
||||
import { useTheming } from '@/store/theme.ts'
|
||||
import { useTheming } from '@/store/theme.js'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -26,16 +26,16 @@ const props = defineProps({
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
const modal = useTemplateRef('modal')
|
||||
const modal = ref(null)
|
||||
|
||||
defineExpose({
|
||||
show: (e: MouseEvent) => {
|
||||
show: () => {
|
||||
hide_ads_window()
|
||||
modal.value?.show(e)
|
||||
modal.value.show()
|
||||
},
|
||||
hide: () => {
|
||||
onModalHide()
|
||||
modal.value?.hide()
|
||||
modal.value.hide()
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { ShareModal } from '@modrinth/ui'
|
||||
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
|
||||
import { useTheming } from '@/store/theme.ts'
|
||||
import { useTheming } from '@/store/theme.js'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
|
||||
import { useTheming } from '@/store/state'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { ref, watch } from 'vue'
|
||||
import { getOS } from '@/helpers/utils'
|
||||
import type { ColorTheme } from '@/store/theme.ts'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -25,13 +24,13 @@ watch(
|
||||
|
||||
<ThemeSelector
|
||||
:update-color-theme="
|
||||
(theme: ColorTheme) => {
|
||||
(theme) => {
|
||||
themeStore.setThemeState(theme)
|
||||
settings.theme = theme
|
||||
}
|
||||
"
|
||||
:current-theme="settings.theme"
|
||||
:theme-options="themeStore.getThemeOptions()"
|
||||
:theme-options="themeStore.themeOptions"
|
||||
system-theme-color="system"
|
||||
/>
|
||||
|
||||
@@ -56,17 +55,9 @@ watch(
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2>
|
||||
<p class="m-0 mt-1">Disables the nametag above your player on the skins page. 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" />
|
||||
@@ -89,28 +80,10 @@ watch(
|
||||
id="opening-page"
|
||||
v-model="settings.default_page"
|
||||
name="Opening page dropdown"
|
||||
class="w-40"
|
||||
:options="['Home', 'Library']"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Jump back into worlds</h2>
|
||||
<p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="themeStore.getFeatureFlag('worlds_in_home')"
|
||||
@update:model-value="
|
||||
() => {
|
||||
const newValue = !themeStore.getFeatureFlag('worlds_in_home')
|
||||
themeStore.featureFlags['worlds_in_home'] = newValue
|
||||
settings.feature_flags['worlds_in_home'] = newValue
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { ref, watch } from 'vue'
|
||||
import { get_max_memory } from '@/helpers/jre'
|
||||
import { handleError } from '@/store/notifications'
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
import { Toggle } from '@modrinth/ui'
|
||||
import { useTheming } from '@/store/state'
|
||||
import { ref, watch } from 'vue'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
const settings = ref(await getSettings())
|
||||
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
|
||||
const settings = ref(await get())
|
||||
const options = ref(['project_background', 'page_path', 'worlds_tab'])
|
||||
|
||||
function setFeatureFlag(key: string, value: boolean) {
|
||||
function getStoreValue(key: string) {
|
||||
return themeStore.featureFlags[key] ?? false
|
||||
}
|
||||
|
||||
function setStoreValue(key: string, value: boolean) {
|
||||
themeStore.featureFlags[key] = value
|
||||
settings.value.feature_flags[key] = value
|
||||
}
|
||||
@@ -18,7 +21,7 @@ function setFeatureFlag(key: string, value: boolean) {
|
||||
watch(
|
||||
settings,
|
||||
async () => {
|
||||
await setSettings(settings.value)
|
||||
await set(settings.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
@@ -33,8 +36,8 @@ watch(
|
||||
|
||||
<Toggle
|
||||
id="advanced-rendering"
|
||||
:model-value="themeStore.getFeatureFlag(option)"
|
||||
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
|
||||
:model-value="getStoreValue(option)"
|
||||
@update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { Toggle } from '@modrinth/ui'
|
||||
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { Button, Slider } from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { get, set } from '@/helpers/settings.js'
|
||||
import { purge_cache_types } from '@/helpers/cache.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
|
||||
|
||||
@@ -1,420 +0,0 @@
|
||||
<script lang="ts">
|
||||
import capeModelUrl from '@/assets/models/cape.gltf?url'
|
||||
import wideModelUrl from '@/assets/models/classic_player.gltf?url'
|
||||
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
|
||||
</script>
|
||||
<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
|
||||
:slim-model-src="slimModelUrl"
|
||||
:wide-model-src="wideModelUrl"
|
||||
:cape-model-src="capeModelUrl"
|
||||
:variant="variant"
|
||||
:texture-src="previewSkin || ''"
|
||||
:cape-src="selectedCapeTexture"
|
||||
:scale="1.4"
|
||||
:fov="50"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Texture</h2>
|
||||
<Button @click="openUploadSkinModal"> <UploadIcon /> Replace texture </Button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Arm style</h2>
|
||||
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
|
||||
<template #default="{ item }">
|
||||
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
|
||||
</template>
|
||||
</RadioButtons>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Cape</h2>
|
||||
<div class="flex gap-2">
|
||||
<CapeButton
|
||||
v-if="defaultCape"
|
||||
:id="defaultCape.id"
|
||||
:texture="defaultCape.texture"
|
||||
:name="undefined"
|
||||
:selected="!selectedCape"
|
||||
faded
|
||||
@select="selectCape(undefined)"
|
||||
>
|
||||
<span>Use default cape</span>
|
||||
</CapeButton>
|
||||
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
|
||||
<span>Use default cape</span>
|
||||
</CapeLikeTextButton>
|
||||
|
||||
<CapeButton
|
||||
v-for="cape in visibleCapeList"
|
||||
:id="cape.id"
|
||||
:key="cape.id"
|
||||
:texture="cape.texture"
|
||||
:name="cape.name || 'Cape'"
|
||||
:selected="selectedCape?.id === cape.id"
|
||||
@select="selectCape(cape)"
|
||||
/>
|
||||
|
||||
<CapeLikeTextButton
|
||||
v-if="(capes?.length ?? 0) > 2"
|
||||
tooltip="View more capes"
|
||||
@mouseup="openSelectCapeModal"
|
||||
>
|
||||
<template #icon><ChevronRightIcon /></template>
|
||||
<span>More</span>
|
||||
</CapeLikeTextButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-12">
|
||||
<ButtonStyled color="brand" :disabled="disableSave || isSaving">
|
||||
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
|
||||
<SpinnerIcon v-if="isSaving" class="animate-spin" />
|
||||
<CheckIcon v-else-if="mode === 'new'" />
|
||||
<SaveIcon v-else />
|
||||
{{ mode === 'new' ? 'Add skin' : 'Save skin' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<Button :disabled="isSaving" @click="hide"><XIcon />Cancel</Button>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
|
||||
<SelectCapeModal
|
||||
ref="selectCapeModal"
|
||||
:capes="capes || []"
|
||||
@select="handleCapeSelected"
|
||||
@cancel="handleCapeCancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, useTemplateRef } from 'vue'
|
||||
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
|
||||
import {
|
||||
SkinPreviewRenderer,
|
||||
Button,
|
||||
RadioButtons,
|
||||
CapeButton,
|
||||
CapeLikeTextButton,
|
||||
ButtonStyled,
|
||||
} from '@modrinth/ui'
|
||||
import {
|
||||
add_and_equip_custom_skin,
|
||||
remove_custom_skin,
|
||||
unequip_skin,
|
||||
type Skin,
|
||||
type Cape,
|
||||
type SkinModel,
|
||||
get_normalized_skin_texture,
|
||||
} from '@/helpers/skins.ts'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import {
|
||||
UploadIcon,
|
||||
CheckIcon,
|
||||
SaveIcon,
|
||||
XIcon,
|
||||
ChevronRightIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
const selectCapeModal = useTemplateRef('selectCapeModal')
|
||||
const mode = ref<'new' | 'edit'>('new')
|
||||
const currentSkin = ref<Skin | null>(null)
|
||||
const shouldRestoreModal = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const uploadedTextureUrl = ref<string | null>(null)
|
||||
const previewSkin = ref<string>('')
|
||||
|
||||
const variant = ref<SkinModel>('CLASSIC')
|
||||
const selectedCape = ref<Cape | undefined>(undefined)
|
||||
const props = defineProps<{ capes?: Cape[]; defaultCape?: Cape }>()
|
||||
|
||||
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
|
||||
const visibleCapeList = ref<Cape[]>([])
|
||||
|
||||
const sortedCapes = computed(() => {
|
||||
return [...(props.capes || [])].sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase()
|
||||
const nameB = (b.name || '').toLowerCase()
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
})
|
||||
|
||||
function initVisibleCapeList() {
|
||||
if (!props.capes || props.capes.length === 0) {
|
||||
visibleCapeList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (visibleCapeList.value.length === 0) {
|
||||
if (selectedCape.value) {
|
||||
const otherCape = getSortedCapeExcluding(selectedCape.value.id)
|
||||
visibleCapeList.value = otherCape ? [selectedCape.value, otherCape] : [selectedCape.value]
|
||||
} else {
|
||||
visibleCapeList.value = getSortedCapes(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSortedCapes(count: number): Cape[] {
|
||||
if (!sortedCapes.value || sortedCapes.value.length === 0) return []
|
||||
return sortedCapes.value.slice(0, count)
|
||||
}
|
||||
|
||||
function getSortedCapeExcluding(excludeId: string): Cape | undefined {
|
||||
if (!sortedCapes.value || sortedCapes.value.length <= 1) return undefined
|
||||
return sortedCapes.value.find((cape) => cape.id !== excludeId)
|
||||
}
|
||||
|
||||
async function loadPreviewSkin() {
|
||||
if (uploadedTextureUrl.value) {
|
||||
previewSkin.value = uploadedTextureUrl.value
|
||||
} else if (currentSkin.value) {
|
||||
try {
|
||||
previewSkin.value = await get_normalized_skin_texture(currentSkin.value)
|
||||
} catch (error) {
|
||||
console.error('Failed to load skin texture:', error)
|
||||
previewSkin.value = '/src/assets/skins/steve.png'
|
||||
}
|
||||
} else {
|
||||
previewSkin.value = '/src/assets/skins/steve.png'
|
||||
}
|
||||
}
|
||||
|
||||
const hasEdits = computed(() => {
|
||||
if (mode.value !== 'edit') return true
|
||||
if (uploadedTextureUrl.value) return true
|
||||
if (!currentSkin.value) return false
|
||||
if (variant.value !== currentSkin.value.variant) return true
|
||||
if ((selectedCape.value?.id || null) !== (currentSkin.value.cape_id || null)) return true
|
||||
return false
|
||||
})
|
||||
|
||||
const disableSave = computed(
|
||||
() =>
|
||||
(mode.value === 'new' && !uploadedTextureUrl.value) ||
|
||||
(mode.value === 'edit' && !hasEdits.value),
|
||||
)
|
||||
|
||||
const saveTooltip = computed(() => {
|
||||
if (isSaving.value) return 'Saving...'
|
||||
if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!'
|
||||
if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!'
|
||||
return undefined
|
||||
})
|
||||
|
||||
function resetState() {
|
||||
mode.value = 'new'
|
||||
currentSkin.value = null
|
||||
uploadedTextureUrl.value = null
|
||||
previewSkin.value = ''
|
||||
variant.value = 'CLASSIC'
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
shouldRestoreModal.value = false
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
async function show(e: MouseEvent, skin?: Skin) {
|
||||
mode.value = skin ? 'edit' : 'new'
|
||||
currentSkin.value = skin ?? null
|
||||
if (skin) {
|
||||
variant.value = skin.variant
|
||||
selectedCape.value = props.capes?.find((c) => c.id === skin.cape_id)
|
||||
} else {
|
||||
variant.value = 'CLASSIC'
|
||||
selectedCape.value = undefined
|
||||
}
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
|
||||
await loadPreviewSkin()
|
||||
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
async function showNew(e: MouseEvent, skinTextureUrl: string) {
|
||||
mode.value = 'new'
|
||||
currentSkin.value = null
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
variant.value = 'CLASSIC'
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
|
||||
await loadPreviewSkin()
|
||||
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
async function restoreWithNewTexture(skinTextureUrl: string) {
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
await loadPreviewSkin()
|
||||
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
setTimeout(() => resetState(), 250)
|
||||
}
|
||||
|
||||
function selectCape(cape: Cape | undefined) {
|
||||
if (cape && selectedCape.value?.id !== cape.id) {
|
||||
const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id)
|
||||
if (!isInVisibleList && visibleCapeList.value.length > 0) {
|
||||
visibleCapeList.value.splice(0, 1, cape)
|
||||
|
||||
if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) {
|
||||
const otherCape = getSortedCapeExcluding(cape.id)
|
||||
if (otherCape) {
|
||||
visibleCapeList.value.splice(1, 1, otherCape)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedCape.value = cape
|
||||
}
|
||||
|
||||
function handleCapeSelected(cape: Cape | undefined) {
|
||||
selectCape(cape)
|
||||
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCapeCancel() {
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function openSelectCapeModal(e: MouseEvent) {
|
||||
if (!selectCapeModal.value) return
|
||||
|
||||
shouldRestoreModal.value = true
|
||||
modal.value?.hide()
|
||||
|
||||
setTimeout(() => {
|
||||
selectCapeModal.value?.show(
|
||||
e,
|
||||
currentSkin.value?.texture_key,
|
||||
selectedCape.value,
|
||||
previewSkin.value,
|
||||
variant.value,
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function openUploadSkinModal(e: MouseEvent) {
|
||||
shouldRestoreModal.value = true
|
||||
modal.value?.hide()
|
||||
emit('open-upload-modal', e)
|
||||
}
|
||||
|
||||
function restoreModal() {
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
modal.value?.show(fakeEvent)
|
||||
shouldRestoreModal.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
let textureUrl: string
|
||||
|
||||
if (uploadedTextureUrl.value) {
|
||||
textureUrl = uploadedTextureUrl.value
|
||||
} else {
|
||||
textureUrl = currentSkin.value!.texture
|
||||
}
|
||||
|
||||
await unequip_skin()
|
||||
|
||||
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
|
||||
|
||||
if (mode.value === 'new') {
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
emit('saved')
|
||||
} else {
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
await remove_custom_skin(currentSkin.value!)
|
||||
emit('saved')
|
||||
}
|
||||
|
||||
hide()
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([uploadedTextureUrl, currentSkin], async () => {
|
||||
await loadPreviewSkin()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.capes,
|
||||
() => {
|
||||
initVisibleCapeList()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'saved'): void
|
||||
(event: 'deleted', skin: Skin): void
|
||||
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
|
||||
}>()
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
showNew,
|
||||
restoreWithNewTexture,
|
||||
hide,
|
||||
shouldRestoreModal,
|
||||
restoreModal,
|
||||
})
|
||||
</script>
|
||||
@@ -1,146 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef, ref, computed } from 'vue'
|
||||
import type { Cape, SkinModel } from '@/helpers/skins.ts'
|
||||
import {
|
||||
ButtonStyled,
|
||||
ScrollablePanel,
|
||||
CapeButton,
|
||||
CapeLikeTextButton,
|
||||
SkinPreviewRenderer,
|
||||
} from '@modrinth/ui'
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import capeModelUrl from '@/assets/models/cape.gltf?url'
|
||||
import wideModelUrl from '@/assets/models/classic_player.gltf?url'
|
||||
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
|
||||
|
||||
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"
|
||||
:slim-model-src="slimModelUrl"
|
||||
:wide-model-src="wideModelUrl"
|
||||
:cape-model-src="capeModelUrl"
|
||||
:cape-src="currentCapeTexture"
|
||||
:texture-src="currentSkinTexture"
|
||||
:variant="currentSkinVariant"
|
||||
:scale="1.4"
|
||||
:fov="50"
|
||||
:initial-rotation="Math.PI + Math.PI / 8"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full my-auto">
|
||||
<ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full">
|
||||
<div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full">
|
||||
<CapeLikeTextButton
|
||||
tooltip="No Cape"
|
||||
:highlighted="!currentCape"
|
||||
@click="updateSelectedCape(undefined)"
|
||||
>
|
||||
<template #icon>
|
||||
<XIcon />
|
||||
</template>
|
||||
<span>None</span>
|
||||
</CapeLikeTextButton>
|
||||
<CapeButton
|
||||
v-for="cape in sortedCapes"
|
||||
:id="cape.id"
|
||||
:key="cape.id"
|
||||
:name="cape.name"
|
||||
:texture="cape.texture"
|
||||
:selected="currentCape?.id === cape.id"
|
||||
@select="updateSelectedCape(cape)"
|
||||
/>
|
||||
</div>
|
||||
</ScrollablePanel>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="select">
|
||||
<CheckIcon />
|
||||
Select
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@@ -1,140 +0,0 @@
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @on-hide="hide(true)">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast"> Upload skin texture </span>
|
||||
</template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2">
|
||||
<UploadIcon /> Select skin texture file
|
||||
</p>
|
||||
<p class="mx-auto mt-0 text-secondary text-sm text-center">
|
||||
Drag and drop or click here to browse
|
||||
</p>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png"
|
||||
class="hidden"
|
||||
@change="handleInputFileChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount, watch } from 'vue'
|
||||
import { UploadIcon } from '@modrinth/assets'
|
||||
import { useNotifications } from '@/store/state'
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get_dragged_skin_data } from '@/helpers/skins'
|
||||
|
||||
const notifications = useNotifications()
|
||||
|
||||
const modal = ref()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const unlisten = ref<() => void>()
|
||||
const modalVisible = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'uploaded', data: ArrayBuffer): void
|
||||
(e: 'canceled'): void
|
||||
}>()
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
modal.value?.show(e)
|
||||
modalVisible.value = true
|
||||
setupDragDropListener()
|
||||
}
|
||||
|
||||
function hide(emitCanceled = false) {
|
||||
modal.value?.hide()
|
||||
modalVisible.value = false
|
||||
cleanupDragDropListener()
|
||||
resetState()
|
||||
if (emitCanceled) {
|
||||
emit('canceled')
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleInputFileChange(e: Event) {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
if (!files || files.length === 0) {
|
||||
return
|
||||
}
|
||||
const file = files[0]
|
||||
const buffer = await file.arrayBuffer()
|
||||
await processData(buffer)
|
||||
}
|
||||
|
||||
async function setupDragDropListener() {
|
||||
try {
|
||||
if (modalVisible.value) {
|
||||
await cleanupDragDropListener()
|
||||
unlisten.value = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
if (event.payload.type !== 'drop') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.payload.paths || event.payload.paths.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = event.payload.paths[0]
|
||||
|
||||
try {
|
||||
const data = await get_dragged_skin_data(filePath)
|
||||
await processData(data.buffer)
|
||||
} catch (error) {
|
||||
notifications.addNotification({
|
||||
title: 'Error processing file',
|
||||
text: error instanceof Error ? error.message : 'Failed to read the dropped file.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set up drag and drop listener:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupDragDropListener() {
|
||||
if (unlisten.value) {
|
||||
unlisten.value()
|
||||
unlisten.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function processData(buffer: ArrayBuffer) {
|
||||
emit('uploaded', buffer)
|
||||
hide()
|
||||
}
|
||||
|
||||
watch(modalVisible, (isVisible) => {
|
||||
if (isVisible) {
|
||||
setupDragDropListener()
|
||||
} else {
|
||||
cleanupDragDropListener()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDragDropListener()
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
EyeIcon,
|
||||
@@ -9,14 +8,7 @@ import {
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
OverflowMenu,
|
||||
SmartClickable,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import { Avatar, ButtonStyled, commonMessages, OverflowMenu, SmartClickable } from '@modrinth/ui'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { showProfileInFolder } from '@/helpers/utils'
|
||||
@@ -33,7 +25,6 @@ import { handleError } from '@/store/notifications'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -43,7 +34,6 @@ const emit = defineEmits<{
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
last_played: Dayjs
|
||||
}>()
|
||||
|
||||
const loadingModpack = ref(!!props.instance.linked_data)
|
||||
@@ -149,12 +139,12 @@ onUnmounted(() => {
|
||||
: null
|
||||
"
|
||||
class="w-fit shrink-0"
|
||||
:class="{ 'cursor-help smart-clickable:allow-pointer-events': last_played }"
|
||||
:class="{ 'cursor-help smart-clickable:allow-pointer-events': instance.last_played }"
|
||||
>
|
||||
<template v-if="last_played">
|
||||
<template v-if="instance.last_played">
|
||||
{{
|
||||
formatMessage(commonMessages.playedLabel, {
|
||||
time: formatRelativeTime(last_played.toISOString?.()),
|
||||
time: dayjs(instance.last_played).fromNow(),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
@@ -163,7 +153,7 @@ onUnmounted(() => {
|
||||
•
|
||||
<span v-if="modpack" class="flex items-center gap-1 truncate text-secondary">
|
||||
<router-link
|
||||
class="inline-flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
|
||||
class="inline-flex items-center gap-1 truncate hover:underline text-secondary"
|
||||
:to="`/project/${modpack.id}`"
|
||||
>
|
||||
<Avatar :src="modpack.icon_url" size="16px" class="shrink-0" />
|
||||
|
||||
@@ -13,17 +13,16 @@ import {
|
||||
import { HeadingLink, GAME_MODES } from '@modrinth/ui'
|
||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
|
||||
import { watch, onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
import { watch, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import { useTheming } from '@/store/theme.ts'
|
||||
import { kill, run } from '@/helpers/profile'
|
||||
import { useTheming } from '@/store/theme'
|
||||
import { kill } from '@/helpers/profile'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener, profile_listener } from '@/helpers/events'
|
||||
import { get_all } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
|
||||
const props = defineProps<{
|
||||
recentInstances: GameInstance[]
|
||||
@@ -55,9 +54,7 @@ type WorldJumpBackInItem = BaseJumpBackInItem & {
|
||||
|
||||
type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
|
||||
|
||||
const showWorlds = computed(() => theme.getFeatureFlag('worlds_in_home'))
|
||||
|
||||
watch([() => props.recentInstances, () => showWorlds.value], async () => {
|
||||
watch(props.recentInstances, async () => {
|
||||
await populateJumpBackIn().catch(() => {
|
||||
console.error('Failed to populate jump back in')
|
||||
})
|
||||
@@ -69,85 +66,80 @@ await populateJumpBackIn().catch(() => {
|
||||
|
||||
async function populateJumpBackIn() {
|
||||
console.info('Repopulating jump back in...')
|
||||
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN)
|
||||
|
||||
const worldItems: WorldJumpBackInItem[] = []
|
||||
worlds.forEach((world) => {
|
||||
const instance = props.recentInstances.find((instance) => instance.path === world.profile)
|
||||
|
||||
if (showWorlds.value) {
|
||||
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN, ['normal', 'favorite'])
|
||||
if (!instance || !world.last_played) {
|
||||
return
|
||||
}
|
||||
|
||||
worlds.forEach((world) => {
|
||||
const instance = props.recentInstances.find((instance) => instance.path === world.profile)
|
||||
|
||||
if (!instance || !world.last_played) {
|
||||
return
|
||||
}
|
||||
|
||||
worldItems.push({
|
||||
type: 'world',
|
||||
last_played: dayjs(world.last_played ?? 0),
|
||||
world: world,
|
||||
instance: instance,
|
||||
})
|
||||
worldItems.push({
|
||||
type: 'world',
|
||||
last_played: dayjs(world.last_played),
|
||||
world: world,
|
||||
instance: instance,
|
||||
})
|
||||
})
|
||||
|
||||
const servers: {
|
||||
instancePath: string
|
||||
address: string
|
||||
}[] = worldItems
|
||||
.filter((item) => item.world.type === 'server' && item.instance)
|
||||
.map((item) => ({
|
||||
instancePath: item.instance.path,
|
||||
address: (item.world as ServerWorld).address,
|
||||
}))
|
||||
const servers: {
|
||||
instancePath: string
|
||||
address: string
|
||||
}[] = worldItems
|
||||
.filter((item) => item.world.type === 'server' && item.instance)
|
||||
.map((item) => ({
|
||||
instancePath: item.instance.path,
|
||||
address: (item.world as ServerWorld).address,
|
||||
}))
|
||||
|
||||
// fetch protocol versions for all unique MC versions with server worlds
|
||||
const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath))
|
||||
await Promise.all(
|
||||
[...uniqueServerInstances].map((path) =>
|
||||
get_profile_protocol_version(path)
|
||||
.then((protoVer) => (protocolVersions.value[path] = protoVer))
|
||||
.catch(() => {
|
||||
console.error(`Failed to get profile protocol for: ${path} `)
|
||||
}),
|
||||
),
|
||||
)
|
||||
// fetch protocol versions for all unique MC versions with server worlds
|
||||
const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath))
|
||||
await Promise.all(
|
||||
[...uniqueServerInstances].map((path) => {
|
||||
get_profile_protocol_version(path)
|
||||
.then((protoVer) => (protocolVersions.value[path] = protoVer))
|
||||
.catch(() => {
|
||||
console.error(`Failed to get profile protocol for: ${path} `)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
// initialize server data
|
||||
servers.forEach(({ address }) => {
|
||||
if (!serverData.value[address]) {
|
||||
serverData.value[address] = {
|
||||
refreshing: true,
|
||||
}
|
||||
// initialize server data
|
||||
servers.forEach(({ address }) => {
|
||||
if (!serverData.value[address]) {
|
||||
serverData.value[address] = {
|
||||
refreshing: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// fetch each server's data
|
||||
Promise.all(
|
||||
servers.map(({ instancePath, address }) =>
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
|
||||
),
|
||||
)
|
||||
}
|
||||
// fetch each server's data
|
||||
await Promise.all(
|
||||
servers.map(({ instancePath, address }) =>
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
|
||||
),
|
||||
)
|
||||
|
||||
const instanceItems: InstanceJumpBackInItem[] = []
|
||||
for (const instance of props.recentInstances) {
|
||||
const worldItem = worldItems.find((item) => item.instance.path === instance.path)
|
||||
if ((worldItem && worldItem.last_played.isAfter(TWO_WEEKS_AGO)) || !instance.last_played) {
|
||||
continue
|
||||
props.recentInstances.forEach((instance) => {
|
||||
if (worldItems.some((item) => item.instance.path === instance.path) || !instance.last_played) {
|
||||
return
|
||||
}
|
||||
|
||||
instanceItems.push({
|
||||
type: 'instance',
|
||||
last_played: dayjs(instance.last_played ?? 0),
|
||||
last_played: dayjs(instance.last_played),
|
||||
instance: instance,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
|
||||
items.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0)))
|
||||
jumpBackInItems.value = items
|
||||
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO))
|
||||
.slice(0, MAX_JUMP_BACK_IN)
|
||||
items.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played)))
|
||||
jumpBackInItems.value = items.filter(
|
||||
(item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO),
|
||||
)
|
||||
}
|
||||
|
||||
async function refreshServer(address: string, instancePath: string) {
|
||||
@@ -163,18 +155,6 @@ async function joinWorld(world: WorldWithProfile) {
|
||||
}
|
||||
}
|
||||
|
||||
async function playInstance(instance: GameInstance) {
|
||||
await run(instance.path)
|
||||
.catch((err) => handleSevereError(err, { profilePath: instance.path }))
|
||||
.finally(() => {
|
||||
trackEvent('InstancePlay', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'WorldItem',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function stopInstance(path: string) {
|
||||
await kill(path).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
@@ -229,7 +209,11 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
|
||||
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1">
|
||||
<HeadingLink
|
||||
v-if="(theme.featureFlags as Record<string, boolean>)['worlds_tab']"
|
||||
to="/worlds"
|
||||
class="mt-1"
|
||||
>
|
||||
Jump back in
|
||||
</HeadingLink>
|
||||
<span
|
||||
@@ -238,7 +222,7 @@ onUnmounted(() => {
|
||||
>
|
||||
Jump back in
|
||||
</span>
|
||||
<div class="grid-when-huge flex flex-col w-full gap-2">
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<template
|
||||
v-for="item in jumpBackInItems"
|
||||
:key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
|
||||
@@ -262,7 +246,7 @@ onUnmounted(() => {
|
||||
:rendered-motd="
|
||||
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
|
||||
"
|
||||
:current-protocol="protocolVersions[item.instance.path]"
|
||||
:current-protocol="protocolVersions[item.instance.game_version]"
|
||||
:game-mode="
|
||||
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
|
||||
"
|
||||
@@ -275,7 +259,6 @@ onUnmounted(() => {
|
||||
? refreshServer(item.world.address, item.instance.path)
|
||||
: {}
|
||||
"
|
||||
@update="() => populateJumpBackIn()"
|
||||
@play="
|
||||
() => {
|
||||
currentProfile = item.instance.path
|
||||
@@ -283,22 +266,10 @@ onUnmounted(() => {
|
||||
joinWorld(item.world)
|
||||
}
|
||||
"
|
||||
@play-instance="
|
||||
() => {
|
||||
currentProfile = item.instance.path
|
||||
playInstance(item.instance)
|
||||
}
|
||||
"
|
||||
@stop="() => stopInstance(item.instance.path)"
|
||||
/>
|
||||
<InstanceItem v-else :instance="item.instance" :last_played="item.last_played" />
|
||||
<InstanceItem v-else :instance="item.instance" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.grid-when-huge {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(670px, 1fr));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts'
|
||||
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
|
||||
import { formatNumber, getPingLevel } from '@modrinth/utils'
|
||||
import {
|
||||
useRelativeTime,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
OverflowMenu,
|
||||
SmartClickable,
|
||||
} from '@modrinth/ui'
|
||||
import type { ServerStatus, ServerWorld, World } from '@/helpers/worlds.ts'
|
||||
import { getWorldIdentifier, showWorldInFolder } from '@/helpers/worlds.ts'
|
||||
import { formatNumber } from '@modrinth/utils'
|
||||
import {
|
||||
IssuesIcon,
|
||||
EyeIcon,
|
||||
@@ -27,8 +19,8 @@ import {
|
||||
TrashIcon,
|
||||
UpdatedIcon,
|
||||
UserIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled, commonMessages, OverflowMenu, SmartClickable } from '@modrinth/ui'
|
||||
import type { MessageDescriptor } from '@vintl/vintl'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import type { Component } from 'vue'
|
||||
@@ -39,13 +31,11 @@ import { useRouter } from 'vue-router'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void
|
||||
(e: 'open-folder', world: SingleplayerWorld): void
|
||||
(e: 'play' | 'stop' | 'refresh' | 'edit' | 'delete'): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -79,7 +69,6 @@ const props = withDefaults(
|
||||
playingWorld: false,
|
||||
startingInstance: false,
|
||||
supportsQuickPlay: false,
|
||||
currentProtocol: null,
|
||||
|
||||
refreshing: false,
|
||||
serverStatus: undefined,
|
||||
@@ -105,6 +94,20 @@ const serverIncompatible = computed(
|
||||
props.serverStatus.version.protocol !== props.currentProtocol,
|
||||
)
|
||||
|
||||
function getPingLevel(ping: number) {
|
||||
if (ping < 150) {
|
||||
return 5
|
||||
} else if (ping < 300) {
|
||||
return 4
|
||||
} else if (ping < 600) {
|
||||
return 3
|
||||
} else if (ping < 1000) {
|
||||
return 2
|
||||
} else {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -140,18 +143,10 @@ const messages = defineMessages({
|
||||
id: 'instance.worlds.play_anyway',
|
||||
defaultMessage: 'Play anyway',
|
||||
},
|
||||
playInstance: {
|
||||
id: 'instance.worlds.play_instance',
|
||||
defaultMessage: 'Play instance',
|
||||
},
|
||||
worldInUse: {
|
||||
id: 'instance.worlds.world_in_use',
|
||||
defaultMessage: 'World is in use',
|
||||
},
|
||||
dontShowOnHome: {
|
||||
id: 'instance.worlds.dont_show_on_home',
|
||||
defaultMessage: `Don't show on Home`,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
@@ -246,7 +241,7 @@ const messages = defineMessages({
|
||||
<template v-if="world.last_played">
|
||||
{{
|
||||
formatMessage(commonMessages.playedLabel, {
|
||||
time: formatRelativeTime(dayjs(world.last_played).toISOString()),
|
||||
time: dayjs(world.last_played).fromNow(),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
@@ -341,12 +336,6 @@ const messages = defineMessages({
|
||||
<ButtonStyled circular type="transparent">
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'play-instance',
|
||||
shown: !!instancePath,
|
||||
disabled: playingInstance,
|
||||
action: () => emit('play-instance'),
|
||||
},
|
||||
{
|
||||
id: 'play-anyway',
|
||||
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
|
||||
@@ -355,7 +344,7 @@ const messages = defineMessages({
|
||||
{
|
||||
id: 'open-instance',
|
||||
shown: !!instancePath,
|
||||
action: () => router.push(encodeURI(`/instance/${instancePath}`)),
|
||||
action: () => router.push(encodeURI(`/instance/${instancePath}/worlds`)),
|
||||
},
|
||||
{
|
||||
id: 'refresh',
|
||||
@@ -377,25 +366,8 @@ const messages = defineMessages({
|
||||
{
|
||||
id: 'open-folder',
|
||||
shown: world.type === 'singleplayer',
|
||||
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}),
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: !!instancePath,
|
||||
},
|
||||
{
|
||||
id: 'dont-show-on-home',
|
||||
shown: !!instancePath,
|
||||
action: () => {
|
||||
set_world_display_status(
|
||||
instancePath,
|
||||
world.type,
|
||||
getWorldIdentifier(world),
|
||||
'hidden',
|
||||
).then(() => {
|
||||
emit('update')
|
||||
})
|
||||
},
|
||||
action: () =>
|
||||
world.type === 'singleplayer' ? showWorldInFolder(instancePath, world.path) : {},
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
@@ -413,10 +385,6 @@ const messages = defineMessages({
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #play-instance>
|
||||
<PlayIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.playInstance) }}
|
||||
</template>
|
||||
<template #play-anyway>
|
||||
<PlayIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.playAnyway) }}
|
||||
@@ -438,10 +406,6 @@ const messages = defineMessages({
|
||||
<template #refresh>
|
||||
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(commonMessages.refreshButton) }}
|
||||
</template>
|
||||
<template #dont-show-on-home>
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.dontShowOnHome) }}
|
||||
</template>
|
||||
<template #delete>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
{{
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { SaveIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, commonMessages } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
type ServerPackStatus,
|
||||
edit_server_in_profile,
|
||||
type ServerWorld,
|
||||
set_world_display_status,
|
||||
type DisplayStatus,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import { edit_server_in_profile, type ServerWorld } from '@/helpers/worlds.ts'
|
||||
import { defineMessage, useVIntl } from '@vintl/vintl'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
|
||||
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -28,14 +21,10 @@ const props = defineProps<{
|
||||
|
||||
const modal = ref()
|
||||
|
||||
const name = ref<string>('')
|
||||
const address = ref<string>('')
|
||||
const resourcePack = ref<ServerPackStatus>('enabled')
|
||||
const index = ref<number>(0)
|
||||
const displayStatus = ref<DisplayStatus>('normal')
|
||||
const hideFromHome = ref(false)
|
||||
|
||||
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
|
||||
const name = ref()
|
||||
const address = ref()
|
||||
const resourcePack = ref('enabled')
|
||||
const index = ref()
|
||||
|
||||
async function saveServer() {
|
||||
const serverName = name.value ? name.value : address.value
|
||||
@@ -47,23 +36,12 @@ async function saveServer() {
|
||||
address.value,
|
||||
resourcePackStatus,
|
||||
).catch(handleError)
|
||||
|
||||
if (newDisplayStatus.value !== displayStatus.value) {
|
||||
await set_world_display_status(
|
||||
props.instance.path,
|
||||
'server',
|
||||
address.value,
|
||||
newDisplayStatus.value,
|
||||
).catch(handleError)
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
name: serverName,
|
||||
type: 'server',
|
||||
index: index.value,
|
||||
address: address.value,
|
||||
pack_status: resourcePackStatus,
|
||||
display_status: newDisplayStatus.value,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
@@ -73,8 +51,6 @@ function show(server: ServerWorld) {
|
||||
address.value = server.address
|
||||
resourcePack.value = server.pack_status
|
||||
index.value = server.index
|
||||
displayStatus.value = server.display_status
|
||||
hideFromHome.value = server.display_status === 'hidden'
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
@@ -99,7 +75,6 @@ const titleMessage = defineMessage({
|
||||
v-model:address="address"
|
||||
v-model:resource-pack="resourcePack"
|
||||
/>
|
||||
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
|
||||
<div class="flex gap-2 mt-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!address" @click="saveServer">
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon, SaveIcon, XIcon, UndoIcon } from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled, commonMessages } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import type { DisplayStatus, SingleplayerWorld } from '@/helpers/worlds.ts'
|
||||
import { set_world_display_status, rename_world, reset_world_icon } from '@/helpers/worlds.ts'
|
||||
import type { SingleplayerWorld } from '@/helpers/worlds.ts'
|
||||
import { rename_world, reset_world_icon } from '@/helpers/worlds.ts'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [path: string, name: string, removeIcon: boolean, displayStatus: DisplayStatus]
|
||||
submit: [path: string, name: string, removeIcon: boolean]
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -26,10 +25,6 @@ const icon = ref()
|
||||
const name = ref()
|
||||
const path = ref()
|
||||
const removeIcon = ref(false)
|
||||
const displayStatus = ref<DisplayStatus>('normal')
|
||||
const hideFromHome = ref(false)
|
||||
|
||||
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
|
||||
|
||||
async function saveWorld() {
|
||||
await rename_world(props.instance.path, path.value, name.value).catch(handleError)
|
||||
@@ -37,16 +32,8 @@ async function saveWorld() {
|
||||
if (removeIcon.value) {
|
||||
await reset_world_icon(props.instance.path, path.value).catch(handleError)
|
||||
}
|
||||
if (newDisplayStatus.value !== displayStatus.value) {
|
||||
await set_world_display_status(
|
||||
props.instance.path,
|
||||
'singleplayer',
|
||||
path.value,
|
||||
newDisplayStatus.value,
|
||||
)
|
||||
}
|
||||
|
||||
emit('submit', path.value, name.value, removeIcon.value, newDisplayStatus.value)
|
||||
emit('submit', path.value, name.value, removeIcon.value)
|
||||
hide()
|
||||
}
|
||||
|
||||
@@ -54,9 +41,6 @@ function show(world: SingleplayerWorld) {
|
||||
name.value = world.name
|
||||
path.value = world.path
|
||||
icon.value = world.icon
|
||||
displayStatus.value = world.display_status
|
||||
hideFromHome.value = world.display_status === 'hidden'
|
||||
removeIcon.value = false
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
@@ -103,7 +87,6 @@ const messages = defineMessages({
|
||||
class="w-full"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<ButtonStyled color="brand">
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessage, useVIntl } from '@vintl/vintl'
|
||||
import { computed } from 'vue'
|
||||
import { Checkbox } from '@modrinth/ui'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const value = defineModel<boolean>({ required: true })
|
||||
|
||||
const labelMessage = defineMessage({
|
||||
id: 'instance.edit-world.hide-from-home',
|
||||
defaultMessage: `Hide from the Home page`,
|
||||
})
|
||||
|
||||
const label = computed(() => formatMessage(labelMessage))
|
||||
</script>
|
||||
<template>
|
||||
<Checkbox v-model="value" :label="label" />
|
||||
</template>
|
||||
@@ -1,12 +1,12 @@
|
||||
import { fetch } from '@tauri-apps/plugin-http'
|
||||
import { ofetch } from 'ofetch'
|
||||
import { handleError } from '@/store/state.js'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
|
||||
export const useFetch = async (url, item, isSilent) => {
|
||||
try {
|
||||
const version = await getVersion()
|
||||
return await fetch(url, {
|
||||
method: 'GET',
|
||||
|
||||
return await ofetch(url, {
|
||||
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -7,13 +7,7 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { create } from './profile'
|
||||
|
||||
// Installs pack from a version ID
|
||||
export async function create_profile_and_install(
|
||||
projectId,
|
||||
versionId,
|
||||
packTitle,
|
||||
iconUrl,
|
||||
createInstanceCallback = () => {},
|
||||
) {
|
||||
export async function create_profile_and_install(projectId, versionId, packTitle, iconUrl) {
|
||||
const location = {
|
||||
type: 'fromVersionId',
|
||||
project_id: projectId,
|
||||
@@ -30,7 +24,6 @@ export async function create_profile_and_install(
|
||||
null,
|
||||
true,
|
||||
)
|
||||
createInstanceCallback(profile)
|
||||
|
||||
return await invoke('plugin:pack|pack_install', { location, profile })
|
||||
}
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
import type { Skin, Cape } from '../skins'
|
||||
import { get_normalized_skin_texture, determineModelType } from '../skins'
|
||||
import { reactive } from 'vue'
|
||||
import { setupSkinModel, disposeCaches } from '@modrinth/utils'
|
||||
import { skinPreviewStorage } from '../storage/skin-preview-storage'
|
||||
import capeModelUrl from '@/assets/models/cape.gltf?url'
|
||||
import wideModelUrl from '@/assets/models/classic_player.gltf?url'
|
||||
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
|
||||
|
||||
export interface RenderResult {
|
||||
forwards: string
|
||||
backwards: string
|
||||
}
|
||||
|
||||
class BatchSkinRenderer {
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private readonly scene: THREE.Scene
|
||||
private readonly camera: THREE.PerspectiveCamera
|
||||
private currentModel: THREE.Group | null = null
|
||||
|
||||
constructor(width: number = 360, height: number = 504) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
canvas: canvas,
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
preserveDrawingBuffer: true,
|
||||
})
|
||||
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
this.renderer.toneMapping = THREE.NoToneMapping
|
||||
this.renderer.toneMappingExposure = 10.0
|
||||
this.renderer.setClearColor(0x000000, 0)
|
||||
this.renderer.setSize(width, height)
|
||||
|
||||
this.scene = new THREE.Scene()
|
||||
this.camera = new THREE.PerspectiveCamera(20, width / height, 0.4, 1000)
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||
directionalLight.castShadow = true
|
||||
directionalLight.position.set(2, 4, 3)
|
||||
this.scene.add(ambientLight)
|
||||
this.scene.add(directionalLight)
|
||||
}
|
||||
|
||||
public async renderSkin(
|
||||
textureUrl: string,
|
||||
modelUrl: string,
|
||||
capeUrl?: string,
|
||||
capeModelUrl?: string,
|
||||
): Promise<RenderResult> {
|
||||
await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
|
||||
|
||||
const headPart = this.currentModel!.getObjectByName('Head')
|
||||
let lookAtTarget: [number, number, number]
|
||||
|
||||
if (headPart) {
|
||||
const headPosition = new THREE.Vector3()
|
||||
headPart.getWorldPosition(headPosition)
|
||||
lookAtTarget = [headPosition.x, headPosition.y - 0.3, headPosition.z]
|
||||
} else {
|
||||
throw new Error("Failed to find 'Head' object in model.")
|
||||
}
|
||||
|
||||
const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3]
|
||||
const backCameraPos: [number, number, number] = [-1.3, 1, -2.5]
|
||||
|
||||
const forwards = await this.renderView(frontCameraPos, lookAtTarget)
|
||||
const backwards = await this.renderView(backCameraPos, lookAtTarget)
|
||||
|
||||
return { forwards, backwards }
|
||||
}
|
||||
|
||||
private async renderView(
|
||||
cameraPosition: [number, number, number],
|
||||
lookAtPosition: [number, number, number],
|
||||
): Promise<string> {
|
||||
this.camera.position.set(...cameraPosition)
|
||||
this.camera.lookAt(...lookAtPosition)
|
||||
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
this.renderer.domElement.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
resolve(url)
|
||||
} else {
|
||||
reject(new Error('Failed to create blob from canvas'))
|
||||
}
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
private async setupModel(
|
||||
modelUrl: string,
|
||||
textureUrl: string,
|
||||
capeModelUrl?: string,
|
||||
capeUrl?: string,
|
||||
): Promise<void> {
|
||||
if (this.currentModel) {
|
||||
this.scene.remove(this.currentModel)
|
||||
}
|
||||
|
||||
const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(model)
|
||||
group.position.set(0, 0.3, 1.95)
|
||||
group.scale.set(0.8, 0.8, 0.8)
|
||||
|
||||
this.scene.add(group)
|
||||
this.currentModel = group
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.renderer.dispose()
|
||||
disposeCaches()
|
||||
}
|
||||
}
|
||||
|
||||
function getModelUrlForVariant(variant: string): string {
|
||||
switch (variant) {
|
||||
case 'SLIM':
|
||||
return slimModelUrl
|
||||
case 'CLASSIC':
|
||||
case 'UNKNOWN':
|
||||
default:
|
||||
return wideModelUrl
|
||||
}
|
||||
}
|
||||
|
||||
export const map = reactive(new Map<string, RenderResult>())
|
||||
export const headMap = reactive(new Map<string, string>())
|
||||
const DEBUG_MODE = false
|
||||
|
||||
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
|
||||
const validKeys = new Set<string>()
|
||||
const validHeadKeys = new Set<string>()
|
||||
|
||||
for (const skin of skins) {
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
const headKey = `${skin.texture_key}-head`
|
||||
validKeys.add(key)
|
||||
validHeadKeys.add(headKey)
|
||||
}
|
||||
|
||||
try {
|
||||
await skinPreviewStorage.cleanupInvalidKeys(validKeys)
|
||||
await skinPreviewStorage.cleanupInvalidKeys(validHeadKeys)
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup unused skin previews:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
const sourceCanvas = document.createElement('canvas')
|
||||
const sourceCtx = sourceCanvas.getContext('2d')
|
||||
|
||||
if (!sourceCtx) {
|
||||
throw new Error('Could not get 2D context from source canvas')
|
||||
}
|
||||
|
||||
sourceCanvas.width = img.width
|
||||
sourceCanvas.height = img.height
|
||||
|
||||
sourceCtx.drawImage(img, 0, 0)
|
||||
|
||||
const outputCanvas = document.createElement('canvas')
|
||||
const outputCtx = outputCanvas.getContext('2d')
|
||||
|
||||
if (!outputCtx) {
|
||||
throw new Error('Could not get 2D context from output canvas')
|
||||
}
|
||||
|
||||
outputCanvas.width = size
|
||||
outputCanvas.height = size
|
||||
|
||||
outputCtx.imageSmoothingEnabled = false
|
||||
|
||||
const headImageData = sourceCtx.getImageData(8, 8, 8, 8)
|
||||
|
||||
const headCanvas = document.createElement('canvas')
|
||||
const headCtx = headCanvas.getContext('2d')
|
||||
|
||||
if (!headCtx) {
|
||||
throw new Error('Could not get 2D context from head canvas')
|
||||
}
|
||||
|
||||
headCanvas.width = 8
|
||||
headCanvas.height = 8
|
||||
headCtx.putImageData(headImageData, 0, 0)
|
||||
|
||||
outputCtx.drawImage(headCanvas, 0, 0, 8, 8, 0, 0, size, size)
|
||||
|
||||
const hatImageData = sourceCtx.getImageData(40, 8, 8, 8)
|
||||
|
||||
const hatCanvas = document.createElement('canvas')
|
||||
const hatCtx = hatCanvas.getContext('2d')
|
||||
|
||||
if (!hatCtx) {
|
||||
throw new Error('Could not get 2D context from hat canvas')
|
||||
}
|
||||
|
||||
hatCanvas.width = 8
|
||||
hatCanvas.height = 8
|
||||
hatCtx.putImageData(hatImageData, 0, 0)
|
||||
|
||||
const hatPixels = hatImageData.data
|
||||
let hasHat = false
|
||||
|
||||
for (let i = 3; i < hatPixels.length; i += 4) {
|
||||
if (hatPixels[i] > 0) {
|
||||
hasHat = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (hasHat) {
|
||||
outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size)
|
||||
}
|
||||
|
||||
outputCanvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('Failed to create blob from canvas'))
|
||||
}
|
||||
}, 'image/png')
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load skin texture image'))
|
||||
}
|
||||
|
||||
img.src = skinUrl
|
||||
})
|
||||
}
|
||||
|
||||
async function generateHeadRender(skin: Skin): Promise<string> {
|
||||
const headKey = `${skin.texture_key}-head`
|
||||
|
||||
if (headMap.has(headKey)) {
|
||||
if (DEBUG_MODE) {
|
||||
const url = headMap.get(headKey)!
|
||||
URL.revokeObjectURL(url)
|
||||
headMap.delete(headKey)
|
||||
} else {
|
||||
return headMap.get(headKey)!
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const cached = await skinPreviewStorage.retrieve(headKey)
|
||||
if (cached && typeof cached === 'string') {
|
||||
headMap.set(headKey, cached)
|
||||
return cached
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to retrieve cached head render:', error)
|
||||
}
|
||||
|
||||
const skinUrl = await get_normalized_skin_texture(skin)
|
||||
const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
|
||||
const headUrl = URL.createObjectURL(headBlob)
|
||||
|
||||
headMap.set(headKey, headUrl)
|
||||
|
||||
try {
|
||||
await skinPreviewStorage.store(headKey, headUrl)
|
||||
} catch (error) {
|
||||
console.warn('Failed to store head render in persistent storage:', error)
|
||||
}
|
||||
|
||||
return headUrl
|
||||
}
|
||||
|
||||
export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
|
||||
return await generateHeadRender(skin)
|
||||
}
|
||||
|
||||
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
|
||||
const renderer = new BatchSkinRenderer()
|
||||
|
||||
try {
|
||||
for (const skin of skins) {
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
|
||||
if (map.has(key)) {
|
||||
if (DEBUG_MODE) {
|
||||
const result = map.get(key)!
|
||||
URL.revokeObjectURL(result.forwards)
|
||||
URL.revokeObjectURL(result.backwards)
|
||||
map.delete(key)
|
||||
} else continue
|
||||
}
|
||||
|
||||
try {
|
||||
const cached = await skinPreviewStorage.retrieve(key)
|
||||
if (cached) {
|
||||
map.set(key, cached)
|
||||
continue
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to retrieve cached skin preview:', error)
|
||||
}
|
||||
|
||||
let variant = skin.variant
|
||||
if (variant === 'UNKNOWN') {
|
||||
try {
|
||||
variant = await determineModelType(skin.texture)
|
||||
} catch (error) {
|
||||
console.error(`Failed to determine model type for skin ${key}:`, error)
|
||||
variant = 'CLASSIC'
|
||||
}
|
||||
}
|
||||
|
||||
const modelUrl = getModelUrlForVariant(variant)
|
||||
const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
|
||||
const renderResult = await renderer.renderSkin(
|
||||
await get_normalized_skin_texture(skin),
|
||||
modelUrl,
|
||||
cape?.texture,
|
||||
capeModelUrl,
|
||||
)
|
||||
|
||||
map.set(key, renderResult)
|
||||
|
||||
try {
|
||||
await skinPreviewStorage.store(key, renderResult)
|
||||
} catch (error) {
|
||||
console.warn('Failed to store skin preview in persistent storage:', error)
|
||||
}
|
||||
|
||||
await generateHeadRender(skin)
|
||||
}
|
||||
} finally {
|
||||
renderer.dispose()
|
||||
await cleanupUnusedPreviews(skins)
|
||||
}
|
||||
}
|
||||
43
apps/app-frontend/src/helpers/settings.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* All theseus API calls return serialized values (both return values and errors);
|
||||
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
// Settings object
|
||||
/*
|
||||
|
||||
Settings {
|
||||
"memory": MemorySettings,
|
||||
"game_resolution": [int int],
|
||||
"custom_java_args": [String ...],
|
||||
"custom_env_args" : [(string, string) ... ]>,
|
||||
"java_globals": Hash of (string, Path),
|
||||
"default_user": Uuid string (can be null),
|
||||
"hooks": Hooks,
|
||||
"max_concurrent_downloads": uint,
|
||||
"version": u32,
|
||||
"collapsed_navigation": bool,
|
||||
}
|
||||
|
||||
Memorysettings {
|
||||
"min": u32, can be null,
|
||||
"max": u32,
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
// Get full settings object
|
||||
export async function get() {
|
||||
return await invoke('plugin:settings|settings_get')
|
||||
}
|
||||
|
||||
// Set full settings object
|
||||
export async function set(settings) {
|
||||
return await invoke('plugin:settings|settings_set', { settings })
|
||||
}
|
||||
|
||||
export async function cancel_directory_change() {
|
||||
return await invoke('plugin:settings|cancel_directory_change')
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* All theseus API calls return serialized values (both return values and errors);
|
||||
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
|
||||
import type { Hooks, MemorySettings, WindowSize } from '@/helpers/types'
|
||||
|
||||
// Settings object
|
||||
/*
|
||||
|
||||
Settings {
|
||||
"memory": MemorySettings,
|
||||
"game_resolution": [int int],
|
||||
"custom_java_args": [String ...],
|
||||
"custom_env_args" : [(string, string) ... ]>,
|
||||
"java_globals": Hash of (string, Path),
|
||||
"default_user": Uuid string (can be null),
|
||||
"hooks": Hooks,
|
||||
"max_concurrent_downloads": uint,
|
||||
"version": u32,
|
||||
"collapsed_navigation": bool,
|
||||
}
|
||||
|
||||
Memorysettings {
|
||||
"min": u32, can be null,
|
||||
"max": u32,
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
export type AppSettings = {
|
||||
max_concurrent_downloads: number
|
||||
max_concurrent_writes: number
|
||||
|
||||
theme: ColorTheme
|
||||
default_page: 'home' | 'library'
|
||||
collapsed_navigation: boolean
|
||||
hide_nametag_skins_page: boolean
|
||||
advanced_rendering: boolean
|
||||
native_decorations: boolean
|
||||
toggle_sidebar: boolean
|
||||
|
||||
telemetry: boolean
|
||||
discord_rpc: boolean
|
||||
personalized_ads: boolean
|
||||
|
||||
onboarded: boolean
|
||||
|
||||
extra_launch_args: string[]
|
||||
custom_env_vars: [string, string][]
|
||||
memory: MemorySettings
|
||||
force_fullscreen: boolean
|
||||
game_resolution: WindowSize
|
||||
hide_on_process_start: boolean
|
||||
hooks: Hooks
|
||||
|
||||
custom_dir?: string | null
|
||||
prev_custom_dir?: string | null
|
||||
migrated: boolean
|
||||
|
||||
developer_mode: boolean
|
||||
feature_flags: Record<FeatureFlag, boolean>
|
||||
}
|
||||
|
||||
// Get full settings object
|
||||
export async function get() {
|
||||
return (await invoke('plugin:settings|settings_get')) as AppSettings
|
||||
}
|
||||
|
||||
// Set full settings object
|
||||
export async function set(settings: AppSettings) {
|
||||
return await invoke('plugin:settings|settings_set', { settings })
|
||||
}
|
||||
|
||||
export async function cancel_directory_change(): Promise<void> {
|
||||
return await invoke('plugin:settings|cancel_directory_change')
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { arrayBufferToBase64 } from '@modrinth/utils'
|
||||
|
||||
export interface Cape {
|
||||
id: string
|
||||
name: string
|
||||
texture: string
|
||||
is_default: boolean
|
||||
is_equipped: boolean
|
||||
}
|
||||
|
||||
export type SkinModel = 'CLASSIC' | 'SLIM' | 'UNKNOWN'
|
||||
export type SkinSource = 'default' | 'custom_external' | 'custom'
|
||||
|
||||
export interface Skin {
|
||||
texture_key: string
|
||||
name?: string
|
||||
variant: SkinModel
|
||||
cape_id?: string
|
||||
texture: string
|
||||
source: SkinSource
|
||||
is_equipped: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[]
|
||||
|
||||
export const DEFAULT_MODELS: Record<string, SkinModel> = {
|
||||
Steve: 'CLASSIC',
|
||||
Alex: 'SLIM',
|
||||
Zuri: 'CLASSIC',
|
||||
Sunny: 'CLASSIC',
|
||||
Noor: 'SLIM',
|
||||
Makena: 'SLIM',
|
||||
Kai: 'CLASSIC',
|
||||
Efe: 'SLIM',
|
||||
Ari: 'CLASSIC',
|
||||
}
|
||||
|
||||
export function filterSavedSkins(list: Skin[]) {
|
||||
const customSkins = list.filter((s) => s.source !== 'default')
|
||||
fixUnknownSkins(customSkins).catch(handleError)
|
||||
return customSkins
|
||||
}
|
||||
|
||||
export async function determineModelType(texture: string): Promise<'SLIM' | 'CLASSIC'> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
if (!context) {
|
||||
return reject(new Error('Failed to create canvas rendering context.'))
|
||||
}
|
||||
|
||||
const image = new Image()
|
||||
image.crossOrigin = 'anonymous'
|
||||
image.src = texture
|
||||
|
||||
image.onload = () => {
|
||||
canvas.width = image.width
|
||||
canvas.height = image.height
|
||||
|
||||
context.drawImage(image, 0, 0)
|
||||
|
||||
const armX = 44
|
||||
const armY = 16
|
||||
const armWidth = 4
|
||||
const armHeight = 12
|
||||
|
||||
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
|
||||
|
||||
for (let y = 0; y < armHeight; y++) {
|
||||
const alphaIndex = (3 + y * armWidth) * 4 + 3
|
||||
if (imageData[alphaIndex] !== 0) {
|
||||
resolve('CLASSIC')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
canvas.remove()
|
||||
resolve('SLIM')
|
||||
}
|
||||
|
||||
image.onerror = () => {
|
||||
canvas.remove()
|
||||
reject(new Error('Failed to load the image.'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function fixUnknownSkins(list: Skin[]) {
|
||||
const unknownSkins = list.filter((s) => s.variant === 'UNKNOWN')
|
||||
for (const unknownSkin of unknownSkins) {
|
||||
unknownSkin.variant = await determineModelType(unknownSkin.texture)
|
||||
}
|
||||
}
|
||||
|
||||
export function filterDefaultSkins(list: Skin[]) {
|
||||
return list
|
||||
.filter((s) => s.source === 'default' && (!s.name || s.variant === DEFAULT_MODELS[s.name]))
|
||||
.sort((a, b) => {
|
||||
const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1
|
||||
const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1
|
||||
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex)
|
||||
})
|
||||
}
|
||||
|
||||
export async function get_available_capes(): Promise<Cape[]> {
|
||||
return invoke('plugin:minecraft-skins|get_available_capes', {})
|
||||
}
|
||||
|
||||
export async function get_available_skins(): Promise<Skin[]> {
|
||||
return invoke('plugin:minecraft-skins|get_available_skins', {})
|
||||
}
|
||||
|
||||
export async function add_and_equip_custom_skin(
|
||||
textureBlob: Uint8Array,
|
||||
variant: SkinModel,
|
||||
capeOverride?: Cape,
|
||||
): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
|
||||
textureBlob,
|
||||
variant,
|
||||
capeOverride,
|
||||
})
|
||||
}
|
||||
|
||||
export async function set_default_cape(cape?: Cape): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|set_default_cape', {
|
||||
cape,
|
||||
})
|
||||
}
|
||||
|
||||
export async function equip_skin(skin: Skin): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|equip_skin', {
|
||||
skin,
|
||||
})
|
||||
}
|
||||
|
||||
export async function remove_custom_skin(skin: Skin): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|remove_custom_skin', {
|
||||
skin,
|
||||
})
|
||||
}
|
||||
|
||||
export async function get_normalized_skin_texture(skin: Skin): Promise<string> {
|
||||
const data = await normalize_skin_texture(skin.texture)
|
||||
const base64 = arrayBufferToBase64(data)
|
||||
return `data:image/png;base64,${base64}`
|
||||
}
|
||||
|
||||
export async function normalize_skin_texture(texture: Uint8Array | string): Promise<Uint8Array> {
|
||||
return await invoke('plugin:minecraft-skins|normalize_skin_texture', { texture })
|
||||
}
|
||||
|
||||
export async function unequip_skin(): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|unequip_skin')
|
||||
}
|
||||
|
||||
export async function get_dragged_skin_data(path: string): Promise<Uint8Array> {
|
||||
const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
|
||||
return new Uint8Array(data)
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { RenderResult } from '../rendering/batch-skin-renderer'
|
||||
|
||||
interface StoredPreview {
|
||||
forwards: Blob
|
||||
backwards: Blob
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export class SkinPreviewStorage {
|
||||
private dbName = 'skin-previews'
|
||||
private version = 1
|
||||
private db: IDBDatabase | null = null
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.version)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result
|
||||
resolve()
|
||||
}
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains('previews')) {
|
||||
db.createObjectStore('previews')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async store(key: string, result: RenderResult): Promise<void> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const forwardsBlob = await fetch(result.forwards).then((r) => r.blob())
|
||||
const backwardsBlob = await fetch(result.backwards).then((r) => r.blob())
|
||||
|
||||
const transaction = this.db!.transaction(['previews'], 'readwrite')
|
||||
const store = transaction.objectStore('previews')
|
||||
|
||||
const storedPreview: StoredPreview = {
|
||||
forwards: forwardsBlob,
|
||||
backwards: backwardsBlob,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.put(storedPreview, key)
|
||||
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async retrieve(key: string): Promise<RenderResult | null> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['previews'], 'readonly')
|
||||
const store = transaction.objectStore('previews')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(key)
|
||||
|
||||
request.onsuccess = () => {
|
||||
const result = request.result as StoredPreview | undefined
|
||||
|
||||
if (!result) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
const forwards = URL.createObjectURL(result.forwards)
|
||||
const backwards = URL.createObjectURL(result.backwards)
|
||||
resolve({ forwards, backwards })
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['previews'], 'readwrite')
|
||||
const store = transaction.objectStore('previews')
|
||||
let deletedCount = 0
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.openCursor()
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
|
||||
|
||||
if (cursor) {
|
||||
const key = cursor.primaryKey as string
|
||||
|
||||
if (!validKeys.has(key)) {
|
||||
const deleteRequest = cursor.delete()
|
||||
deleteRequest.onsuccess = () => {
|
||||
deletedCount++
|
||||
}
|
||||
deleteRequest.onerror = () => {
|
||||
console.warn('Failed to delete invalid entry:', key)
|
||||
}
|
||||
}
|
||||
|
||||
cursor.continue()
|
||||
} else {
|
||||
resolve(deletedCount)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const skinPreviewStorage = new SkinPreviewStorage()
|
||||
27
apps/app-frontend/src/helpers/types.d.ts
vendored
@@ -48,32 +48,6 @@ type LinkedData = {
|
||||
|
||||
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
|
||||
|
||||
type ContentFile = {
|
||||
hash: string
|
||||
file_name: string
|
||||
size: number
|
||||
metadata?: FileMetadata
|
||||
update_version_id?: string
|
||||
project_type: ContentFileProjectType
|
||||
}
|
||||
|
||||
type FileMetadata = {
|
||||
project_id: string
|
||||
version_id: string
|
||||
}
|
||||
|
||||
type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack'
|
||||
|
||||
type CacheBehaviour =
|
||||
// Serve expired data. If fetch fails / launcher is offline, errors are ignored
|
||||
| 'stale_while_revalidate_skip_offline'
|
||||
// Serve expired data, revalidate in background
|
||||
| 'stale_while_revalidate'
|
||||
// Must revalidate if data is expired
|
||||
| 'must_revalidate'
|
||||
// Ignore cache- always fetch updated data from origin
|
||||
| 'bypass'
|
||||
|
||||
type MemorySettings = {
|
||||
maximum: number
|
||||
}
|
||||
@@ -114,7 +88,6 @@ type AppSettings = {
|
||||
collapsed_navigation: boolean
|
||||
advanced_rendering: boolean
|
||||
native_decorations: boolean
|
||||
worlds_in_home: boolean
|
||||
|
||||
telemetry: boolean
|
||||
discord_rpc: boolean
|
||||
|
||||
@@ -9,13 +9,8 @@ type BaseWorld = {
|
||||
name: string
|
||||
last_played?: string
|
||||
icon?: string
|
||||
display_status: DisplayStatus
|
||||
type: WorldType
|
||||
}
|
||||
|
||||
export type WorldType = 'singleplayer' | 'server'
|
||||
export type DisplayStatus = 'normal' | 'hidden' | 'favorite'
|
||||
|
||||
export type SingleplayerWorld = BaseWorld & {
|
||||
type: 'singleplayer'
|
||||
path: string
|
||||
@@ -75,11 +70,8 @@ export type ServerData = {
|
||||
renderedMotd?: string
|
||||
}
|
||||
|
||||
export async function get_recent_worlds(
|
||||
limit: number,
|
||||
displayStatuses?: DisplayStatus[],
|
||||
): Promise<WorldWithProfile[]> {
|
||||
return await invoke('plugin:worlds|get_recent_worlds', { limit, displayStatuses })
|
||||
export async function get_recent_worlds(limit: number): Promise<WorldWithProfile[]> {
|
||||
return await invoke('plugin:worlds|get_recent_worlds', { limit })
|
||||
}
|
||||
|
||||
export async function get_profile_worlds(path: string): Promise<World[]> {
|
||||
@@ -93,20 +85,6 @@ export async function get_singleplayer_world(
|
||||
return await invoke('plugin:worlds|get_singleplayer_world', { instance, world })
|
||||
}
|
||||
|
||||
export async function set_world_display_status(
|
||||
instance: string,
|
||||
worldType: WorldType,
|
||||
worldId: string,
|
||||
displayStatus: DisplayStatus,
|
||||
): Promise<void> {
|
||||
return await invoke('plugin:worlds|set_world_display_status', {
|
||||
instance,
|
||||
worldType,
|
||||
worldId,
|
||||
displayStatus,
|
||||
})
|
||||
}
|
||||
|
||||
export async function rename_world(
|
||||
instance: string,
|
||||
world: string,
|
||||
@@ -252,14 +230,12 @@ export async function refreshServers(
|
||||
|
||||
export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) {
|
||||
const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath)
|
||||
const newWorld = await get_singleplayer_world(instancePath, worldPath)
|
||||
if (index !== -1) {
|
||||
worlds[index] = newWorld
|
||||
worlds[index] = await get_singleplayer_world(instancePath, worldPath)
|
||||
sortWorlds(worlds)
|
||||
} else {
|
||||
console.info(`Adding new world at path: ${worldPath}.`)
|
||||
worlds.push(newWorld)
|
||||
console.error(`Error refreshing world, could not find world at path ${worldPath}.`)
|
||||
}
|
||||
sortWorlds(worlds)
|
||||
}
|
||||
|
||||
export async function handleDefaultProfileUpdateEvent(
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"instance.edit-server.title": {
|
||||
"message": "Edit server"
|
||||
},
|
||||
"instance.edit-world.hide-from-home": {
|
||||
"message": "Hide from the Home page"
|
||||
},
|
||||
"instance.edit-world.name": {
|
||||
"message": "Name"
|
||||
},
|
||||
@@ -365,9 +362,6 @@
|
||||
"instance.worlds.copy_address": {
|
||||
"message": "Copy address"
|
||||
},
|
||||
"instance.worlds.dont_show_on_home": {
|
||||
"message": "Don't show on Home"
|
||||
},
|
||||
"instance.worlds.filter.available": {
|
||||
"message": "Available"
|
||||
},
|
||||
@@ -383,9 +377,6 @@
|
||||
"instance.worlds.play_anyway": {
|
||||
"message": "Play anyway"
|
||||
},
|
||||
"instance.worlds.play_instance": {
|
||||
"message": "Play instance"
|
||||
},
|
||||
"instance.worlds.type.server": {
|
||||
"message": "Server"
|
||||
},
|
||||
|
||||
@@ -220,7 +220,6 @@ async function refreshSearch() {
|
||||
}
|
||||
}
|
||||
results.value = rawResults.result
|
||||
currentPage.value = Math.max(1, Math.min(pageCount.value, currentPage.value))
|
||||
|
||||
const persistentParams: LocationQuery = {}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import dayjs from 'dayjs'
|
||||
import { get_search_results } from '@/helpers/cache.js'
|
||||
import type { SearchResult } from '@modrinth/utils'
|
||||
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const route = useRoute()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
@@ -83,15 +82,13 @@ async function refreshFeaturedProjects() {
|
||||
await fetchInstances()
|
||||
await refreshFeaturedProjects()
|
||||
|
||||
const unlistenProfile = await profile_listener(
|
||||
async (e: { event: string; profile_path_id: string }) => {
|
||||
await fetchInstances()
|
||||
const unlistenProfile = await profile_listener(async (e) => {
|
||||
await fetchInstances()
|
||||
|
||||
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
||||
await refreshFeaturedProjects()
|
||||
}
|
||||
},
|
||||
)
|
||||
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
||||
await refreshFeaturedProjects()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProfile()
|
||||
@@ -100,8 +97,8 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="p-6 flex flex-col gap-2">
|
||||
<h1 v-if="recentInstances?.length > 0" class="m-0 text-2xl font-extrabold">Welcome back!</h1>
|
||||
<h1 v-else class="m-0 text-2xl font-extrabold">Welcome to Modrinth App!</h1>
|
||||
<h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
|
||||
<h1 v-else class="m-0 text-2xl">Welcome to Modrinth App!</h1>
|
||||
<RecentWorldsList :recent-instances="recentInstances" />
|
||||
<RowDisplay
|
||||
v-if="hasFeaturedProjects"
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
EditIcon,
|
||||
ExcitedRinthbot,
|
||||
LogInIcon,
|
||||
PlusIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
UpdatedIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
ButtonStyled,
|
||||
ConfirmModal,
|
||||
SkinButton,
|
||||
SkinLikeTextButton,
|
||||
SkinPreviewRenderer,
|
||||
} from '@modrinth/ui'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
|
||||
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
|
||||
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
|
||||
import { handleError, useNotifications } from '@/store/notifications'
|
||||
import type { Cape, Skin } from '@/helpers/skins.ts'
|
||||
import {
|
||||
normalize_skin_texture,
|
||||
equip_skin,
|
||||
filterDefaultSkins,
|
||||
filterSavedSkins,
|
||||
get_available_capes,
|
||||
get_available_skins,
|
||||
get_normalized_skin_texture,
|
||||
remove_custom_skin,
|
||||
set_default_cape,
|
||||
} from '@/helpers/skins.ts'
|
||||
import { get as getSettings } from '@/helpers/settings.ts'
|
||||
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
|
||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { generateSkinPreviews, map } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import { arrayBufferToBase64 } from '@modrinth/utils'
|
||||
import capeModelUrl from '@/assets/models/cape.gltf?url'
|
||||
import wideModelUrl from '@/assets/models/classic_player.gltf?url'
|
||||
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
|
||||
|
||||
const editSkinModal = useTemplateRef('editSkinModal')
|
||||
const selectCapeModal = useTemplateRef('selectCapeModal')
|
||||
const uploadSkinModal = useTemplateRef('uploadSkinModal')
|
||||
|
||||
const notifications = useNotifications()
|
||||
|
||||
const settings = ref(await getSettings())
|
||||
const skins = ref<Skin[]>([])
|
||||
const capes = ref<Cape[]>([])
|
||||
|
||||
const accountsCard = inject('accountsCard') as Ref<typeof AccountsCard>
|
||||
const currentUser = ref(undefined)
|
||||
const currentUserId = ref<string | undefined>(undefined)
|
||||
|
||||
const username = computed(() => currentUser.value?.profile?.name ?? undefined)
|
||||
const selectedSkin = ref<Skin | null>(null)
|
||||
const defaultCape = ref<Cape>()
|
||||
|
||||
const originalSelectedSkin = ref<Skin | null>(null)
|
||||
const originalDefaultCape = ref<Cape>()
|
||||
|
||||
const savedSkins = computed(() => filterSavedSkins(skins.value))
|
||||
const defaultSkins = computed(() => filterDefaultSkins(skins.value))
|
||||
|
||||
const currentCape = computed(() => {
|
||||
if (selectedSkin.value?.cape_id) {
|
||||
const overrideCape = capes.value.find((c) => c.id === selectedSkin.value?.cape_id)
|
||||
if (overrideCape) {
|
||||
return overrideCape
|
||||
}
|
||||
}
|
||||
return defaultCape.value
|
||||
})
|
||||
|
||||
const skinTexture = computedAsync(async () => {
|
||||
if (selectedSkin.value?.texture) {
|
||||
return await get_normalized_skin_texture(selectedSkin.value)
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
const capeTexture = computed(() => currentCape.value?.texture)
|
||||
const skinVariant = computed(() => selectedSkin.value?.variant)
|
||||
const skinNametag = computed(() =>
|
||||
settings.value.hide_nametag_skins_page ? undefined : username.value,
|
||||
)
|
||||
|
||||
let userCheckInterval: number | null = null
|
||||
|
||||
const deleteSkinModal = ref()
|
||||
const skinToDelete = ref<Skin | null>(null)
|
||||
|
||||
function confirmDeleteSkin(skin: Skin) {
|
||||
skinToDelete.value = skin
|
||||
deleteSkinModal.value?.show()
|
||||
}
|
||||
|
||||
async function deleteSkin() {
|
||||
if (!skinToDelete.value) return
|
||||
await remove_custom_skin(skinToDelete.value).catch(handleError)
|
||||
await loadSkins()
|
||||
skinToDelete.value = null
|
||||
}
|
||||
|
||||
async function loadCapes() {
|
||||
try {
|
||||
capes.value = (await get_available_capes()) ?? []
|
||||
defaultCape.value = capes.value.find((c) => c.is_equipped)
|
||||
originalDefaultCape.value = defaultCape.value
|
||||
} catch (error) {
|
||||
if (currentUser.value) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkins() {
|
||||
try {
|
||||
skins.value = (await get_available_skins()) ?? []
|
||||
generateSkinPreviews(skins.value, capes.value)
|
||||
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
|
||||
originalSelectedSkin.value = selectedSkin.value
|
||||
} catch (error) {
|
||||
if (currentUser.value) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSkin(newSkin: Skin) {
|
||||
const previousSkin = selectedSkin.value
|
||||
const previousSkinsList = [...skins.value]
|
||||
|
||||
skins.value = skins.value.map((skin) => {
|
||||
return {
|
||||
...skin,
|
||||
is_equipped: skin.texture_key === newSkin.texture_key,
|
||||
}
|
||||
})
|
||||
|
||||
selectedSkin.value = skins.value.find((s) => s.texture_key === newSkin.texture_key) || null
|
||||
|
||||
try {
|
||||
await equip_skin(newSkin)
|
||||
if (accountsCard.value) {
|
||||
await accountsCard.value.refreshValues()
|
||||
}
|
||||
} catch (error) {
|
||||
selectedSkin.value = previousSkin
|
||||
skins.value = previousSkinsList
|
||||
|
||||
if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) {
|
||||
notifications.addNotification({
|
||||
type: 'error',
|
||||
title: 'Slow down!',
|
||||
text: "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
|
||||
})
|
||||
} else {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCapeSelected(cape: Cape | undefined) {
|
||||
const previousDefaultCape = defaultCape.value
|
||||
const previousCapesList = [...capes.value]
|
||||
|
||||
capes.value = capes.value.map((c) => ({
|
||||
...c,
|
||||
is_equipped: cape ? c.id === cape.id : false,
|
||||
}))
|
||||
|
||||
defaultCape.value = cape ? capes.value.find((c) => c.id === cape.id) : undefined
|
||||
|
||||
try {
|
||||
await set_default_cape(cape)
|
||||
} catch (error) {
|
||||
defaultCape.value = previousDefaultCape
|
||||
capes.value = previousCapesList
|
||||
|
||||
if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) {
|
||||
notifications.addNotification({
|
||||
type: 'error',
|
||||
title: 'Slow down!',
|
||||
text: "You're changing your cape too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
|
||||
})
|
||||
} else {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onSkinSaved() {
|
||||
await Promise.all([loadCapes(), loadSkins()])
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
try {
|
||||
const defaultId = await get_default_user()
|
||||
currentUserId.value = defaultId
|
||||
|
||||
const allAccounts = await users()
|
||||
currentUser.value = allAccounts.find((acc) => acc.profile.id === defaultId)
|
||||
} catch (e) {
|
||||
handleError(e)
|
||||
currentUser.value = undefined
|
||||
currentUserId.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
return map.get(key)
|
||||
}
|
||||
|
||||
async function login() {
|
||||
accountsCard.value.setLoginDisabled(true)
|
||||
const loggedIn = await login_flow().catch(handleSevereError)
|
||||
|
||||
if (loggedIn && accountsCard) {
|
||||
await accountsCard.value.refreshValues()
|
||||
}
|
||||
|
||||
trackEvent('AccountLogIn')
|
||||
accountsCard.value.setLoginDisabled(false)
|
||||
}
|
||||
|
||||
function openUploadSkinModal(e: MouseEvent) {
|
||||
uploadSkinModal.value?.show(e)
|
||||
}
|
||||
|
||||
function onSkinFileUploaded(buffer: ArrayBuffer) {
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
normalize_skin_texture(`data:image/png;base64,` + arrayBufferToBase64(buffer)).then(
|
||||
(skinTextureNormalized: Uint8Array) => {
|
||||
const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized)
|
||||
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
|
||||
editSkinModal.value.restoreWithNewTexture(skinTexUrl)
|
||||
} else {
|
||||
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function onUploadCanceled() {
|
||||
editSkinModal.value?.restoreModal()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selectedSkin.value?.cape_id,
|
||||
() => {},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
userCheckInterval = window.setInterval(checkUserChanges, 250)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (userCheckInterval !== null) {
|
||||
window.clearInterval(userCheckInterval)
|
||||
}
|
||||
})
|
||||
|
||||
async function checkUserChanges() {
|
||||
try {
|
||||
const defaultId = await get_default_user()
|
||||
if (defaultId !== currentUserId.value) {
|
||||
await loadCurrentUser()
|
||||
await loadCapes()
|
||||
await loadSkins()
|
||||
}
|
||||
} catch (error) {
|
||||
if (currentUser.value) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EditSkinModal
|
||||
ref="editSkinModal"
|
||||
:capes="capes"
|
||||
:default-cape="defaultCape"
|
||||
@saved="onSkinSaved"
|
||||
@deleted="() => loadSkins()"
|
||||
@open-upload-modal="openUploadSkinModal"
|
||||
/>
|
||||
<SelectCapeModal ref="selectCapeModal" :capes="capes" @select="handleCapeSelected" />
|
||||
<UploadSkinModal
|
||||
ref="uploadSkinModal"
|
||||
@uploaded="onSkinFileUploaded"
|
||||
@canceled="onUploadCanceled"
|
||||
/>
|
||||
<ConfirmModal
|
||||
ref="deleteSkinModal"
|
||||
title="Are you sure you want to delete this skin?"
|
||||
description="This will permanently delete the selected skin. This action cannot be undone."
|
||||
proceed-label="Delete"
|
||||
@proceed="deleteSkin"
|
||||
/>
|
||||
|
||||
<div v-if="currentUser" class="p-4 skin-layout">
|
||||
<div class="preview-panel">
|
||||
<h1 class="m-0 text-2xl font-bold flex items-center gap-2">
|
||||
Skins
|
||||
<span class="text-sm font-bold px-2 bg-brand-highlight text-brand rounded-full">Beta</span>
|
||||
</h1>
|
||||
<div class="preview-container">
|
||||
<SkinPreviewRenderer
|
||||
:wide-model-src="wideModelUrl"
|
||||
:slim-model-src="slimModelUrl"
|
||||
:cape-model-src="capeModelUrl"
|
||||
:cape-src="capeTexture"
|
||||
:texture-src="skinTexture || ''"
|
||||
:variant="skinVariant"
|
||||
:nametag="skinNametag"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
>
|
||||
<template #subtitle>
|
||||
<ButtonStyled :disabled="!!selectedSkin?.cape_id">
|
||||
<button
|
||||
v-tooltip="
|
||||
selectedSkin?.cape_id
|
||||
? 'The equipped skin is overriding the default cape.'
|
||||
: undefined
|
||||
"
|
||||
:disabled="!!selectedSkin?.cape_id"
|
||||
@click="
|
||||
(e: MouseEvent) =>
|
||||
selectCapeModal?.show(
|
||||
e,
|
||||
selectedSkin?.texture_key,
|
||||
currentCape,
|
||||
skinTexture,
|
||||
skinVariant,
|
||||
)
|
||||
"
|
||||
>
|
||||
<UpdatedIcon />
|
||||
Change cape
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinPreviewRenderer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skins-container">
|
||||
<section class="flex flex-col gap-2 mt-1">
|
||||
<h2 class="text-lg font-bold m-0 text-primary">Saved skins</h2>
|
||||
<div class="skin-card-grid">
|
||||
<SkinLikeTextButton class="skin-card" @click="openUploadSkinModal">
|
||||
<template #icon>
|
||||
<PlusIcon class="size-8" />
|
||||
</template>
|
||||
<span>Add a skin</span>
|
||||
</SkinLikeTextButton>
|
||||
|
||||
<SkinButton
|
||||
v-for="skin in savedSkins"
|
||||
:key="`saved-skin-${skin.texture_key}`"
|
||||
class="skin-card"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="selectedSkin === skin"
|
||||
@select="changeSkin(skin)"
|
||||
>
|
||||
<template #overlay-buttons>
|
||||
<Button
|
||||
color="green"
|
||||
aria-label="Edit skin"
|
||||
class="pointer-events-auto"
|
||||
@click.stop="(e) => editSkinModal?.show(e, skin)"
|
||||
>
|
||||
<EditIcon /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
v-show="!skin.is_equipped"
|
||||
v-tooltip="'Delete skin'"
|
||||
aria-label="Delete skin"
|
||||
color="red"
|
||||
class="!rounded-[100%] pointer-events-auto"
|
||||
icon-only
|
||||
@click.stop="() => confirmDeleteSkin(skin)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2 mt-6">
|
||||
<h2 class="text-lg font-bold m-0 text-primary">Default skins</h2>
|
||||
<div class="skin-card-grid">
|
||||
<SkinButton
|
||||
v-for="skin in defaultSkins"
|
||||
:key="`default-skin-${skin.texture_key}`"
|
||||
class="skin-card"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="selectedSkin === skin"
|
||||
:tooltip="skin.name"
|
||||
@select="changeSkin(skin)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center justify-center min-h-[50vh] pt-[25%]">
|
||||
<div
|
||||
class="bg-bg-raised rounded-lg p-7 flex flex-col gap-5 shadow-md relative max-w-xl w-full mx-auto"
|
||||
>
|
||||
<img
|
||||
:src="ExcitedRinthbot"
|
||||
alt="Excited Modrinth Bot"
|
||||
class="absolute -top-28 right-8 md:right-20 h-28 w-auto"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-[1px] opacity-40 bg-gradient-to-r from-transparent via-green-500 to-transparent"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 2rem,
|
||||
var(--color-green) calc(100% - 13rem),
|
||||
var(--color-green) calc(100% - 5rem),
|
||||
transparent calc(100% - 2rem)
|
||||
);
|
||||
"
|
||||
></div>
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
<h1 class="text-3xl font-extrabold m-0">Please sign-in</h1>
|
||||
<p class="text-lg m-0">
|
||||
Please sign into your Minecraft account to use the skin management features of the
|
||||
Modrinth app.
|
||||
</p>
|
||||
<ButtonStyled v-show="accountsCard" color="brand" :disabled="accountsCard.loginDisabled">
|
||||
<button :disabled="accountsCard.loginDisabled" @click="login">
|
||||
<LogInIcon v-if="!accountsCard.loginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
Sign In
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$skin-card-width: 155px;
|
||||
$skin-card-gap: 4px;
|
||||
|
||||
.skin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2.5fr);
|
||||
gap: 2.5rem;
|
||||
|
||||
@media (max-width: 700px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
top: 1.5rem;
|
||||
position: sticky;
|
||||
align-self: start;
|
||||
padding: 0.5rem;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
height: 80vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: calc((2.5rem / 2));
|
||||
|
||||
@media (max-width: 700px) {
|
||||
height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
.skins-container {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.skin-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: $skin-card-gap;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 1300px) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1750px) {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 2050px) {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.skin-card {
|
||||
aspect-ratio: 0.95;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,5 @@
|
||||
import Index from './Index.vue'
|
||||
import Browse from './Browse.vue'
|
||||
import Worlds from './Worlds.vue'
|
||||
import Skins from './Skins.vue'
|
||||
|
||||
export { Index, Browse, Worlds, Skins }
|
||||
export { Index, Browse, Worlds }
|
||||
|
||||
@@ -32,11 +32,7 @@
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-if="
|
||||
['installing', 'pack_installing', 'minecraft_installing'].includes(
|
||||
instance.install_stage,
|
||||
)
|
||||
"
|
||||
v-if="instance.install_stage.includes('installing')"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
|
||||
<button
|
||||
v-for="filter in filterOptions"
|
||||
:key="`content-filter-${filter.id}`"
|
||||
:key="filter"
|
||||
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
|
||||
@click="toggleArray(selectedFilters, filter.id)"
|
||||
>
|
||||
@@ -47,7 +47,7 @@
|
||||
path: x.path,
|
||||
disabled: x.disabled,
|
||||
filename: x.file_name,
|
||||
icon: x.icon ?? undefined,
|
||||
icon: x.icon,
|
||||
title: x.name,
|
||||
data: x,
|
||||
}
|
||||
@@ -156,7 +156,7 @@
|
||||
color-fill="text"
|
||||
hover-color-fill="text"
|
||||
>
|
||||
<button class="w-max" :disabled="installing" @click="modpackVersionModal?.show()">
|
||||
<button class="w-max" :disabled="installing" @click="modpackVersionModal.show()">
|
||||
<DownloadIcon /> Update pack
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -170,7 +170,7 @@
|
||||
>
|
||||
<button
|
||||
v-tooltip="`Update`"
|
||||
:disabled="(item.data as ProjectListEntry).updating"
|
||||
:disabled="(item.data as any).updating"
|
||||
@click="updateProject(item.data)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
@@ -276,7 +276,6 @@ import {
|
||||
RadialHeader,
|
||||
Toggle,
|
||||
} from '@modrinth/ui'
|
||||
import type { Organization, Project, TeamMember, Version } from '@modrinth/utils'
|
||||
import { formatProjectType } from '@modrinth/utils'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
@@ -306,18 +305,43 @@ import { profile_listener } from '@/helpers/events.js'
|
||||
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
||||
import dayjs from 'dayjs'
|
||||
import type { CacheBehaviour, ContentFile, GameInstance } from '@/helpers/types'
|
||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type { ContentItem } from '@modrinth/ui/src/components/content/ContentListItem.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
options: InstanceType<typeof ContextMenu>
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
versions: Version[]
|
||||
installed: boolean
|
||||
}>()
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
offline: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
playing: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
installed: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type ProjectListEntryAuthor = {
|
||||
name: string
|
||||
@@ -332,15 +356,13 @@ type ProjectListEntry = {
|
||||
author: ProjectListEntryAuthor | null
|
||||
version: string | null
|
||||
file_name: string
|
||||
icon: string | undefined
|
||||
icon: string | null
|
||||
disabled: boolean
|
||||
updateVersion?: string
|
||||
outdated: boolean
|
||||
updated: dayjs.Dayjs
|
||||
project_type: string
|
||||
id?: string
|
||||
updating?: boolean
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
const isPackLocked = computed(() => {
|
||||
@@ -353,20 +375,17 @@ const canUpdatePack = computed(() => {
|
||||
const exportModal = ref(null)
|
||||
|
||||
const projects = ref<ProjectListEntry[]>([])
|
||||
const selectedFiles = ref<string[]>([])
|
||||
const selectedFiles = ref([])
|
||||
const selectedProjects = computed(() =>
|
||||
projects.value.filter((x) => selectedFiles.value.includes(x.file_name)),
|
||||
)
|
||||
|
||||
const selectionMap = ref(new Map())
|
||||
|
||||
const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
|
||||
const initProjects = async (cacheBehaviour?) => {
|
||||
const newProjects: ProjectListEntry[] = []
|
||||
|
||||
const profileProjects = (await get_projects(props.instance.path, cacheBehaviour)) as Record<
|
||||
string,
|
||||
ContentFile
|
||||
>
|
||||
const profileProjects = await get_projects(props.instance.path, cacheBehaviour)
|
||||
const fetchProjects = []
|
||||
const fetchVersions = []
|
||||
|
||||
@@ -378,21 +397,21 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
|
||||
}
|
||||
|
||||
const [modrinthProjects, modrinthVersions] = await Promise.all([
|
||||
(await get_project_many(fetchProjects).catch(handleError)) as Project[],
|
||||
(await get_version_many(fetchVersions).catch(handleError)) as Version[],
|
||||
await get_project_many(fetchProjects).catch(handleError),
|
||||
await get_version_many(fetchVersions).catch(handleError),
|
||||
])
|
||||
|
||||
const [modrinthTeams, modrinthOrganizations] = await Promise.all([
|
||||
(await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError)) as TeamMember[][],
|
||||
(await get_organization_many(
|
||||
await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError),
|
||||
await get_organization_many(
|
||||
modrinthProjects.map((x) => x.organization).filter((x) => !!x),
|
||||
).catch(handleError)) as Organization[],
|
||||
).catch(handleError),
|
||||
])
|
||||
|
||||
for (const [path, file] of Object.entries(profileProjects)) {
|
||||
if (file.metadata) {
|
||||
const project = modrinthProjects.find((x) => file.metadata?.project_id === x.id)
|
||||
const version = modrinthVersions.find((x) => file.metadata?.version_id === x.id)
|
||||
const project = modrinthProjects.find((x) => file.metadata.project_id === x.id)
|
||||
const version = modrinthVersions.find((x) => file.metadata.version_id === x.id)
|
||||
|
||||
if (project && version) {
|
||||
const org = project.organization
|
||||
@@ -401,7 +420,7 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
|
||||
|
||||
const team = modrinthTeams.find((x) => x[0].team_id === project.team)
|
||||
|
||||
let author: ProjectListEntryAuthor | null = null
|
||||
let author: ProjectListEntryAuthor | null
|
||||
if (org) {
|
||||
author = {
|
||||
name: org.name,
|
||||
@@ -410,13 +429,13 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
|
||||
}
|
||||
} else if (team) {
|
||||
const teamMember = team.find((x) => x.is_owner)
|
||||
if (teamMember) {
|
||||
author = {
|
||||
name: teamMember.user.username,
|
||||
slug: teamMember.user.username,
|
||||
type: 'user',
|
||||
}
|
||||
author = {
|
||||
name: teamMember.user.username,
|
||||
slug: teamMember.user.username,
|
||||
type: 'user',
|
||||
}
|
||||
} else {
|
||||
author = null
|
||||
}
|
||||
|
||||
newProjects.push({
|
||||
@@ -445,7 +464,7 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
|
||||
author: null,
|
||||
version: null,
|
||||
file_name: file.file_name,
|
||||
icon: undefined,
|
||||
icon: null,
|
||||
disabled: file.file_name.endsWith('.disabled'),
|
||||
outdated: false,
|
||||
updated: dayjs(0),
|
||||
@@ -469,7 +488,7 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
|
||||
}
|
||||
await initProjects()
|
||||
|
||||
const modpackVersionModal = ref<InstanceType<typeof ModpackVersionModal> | null>()
|
||||
const modpackVersionModal = ref(null)
|
||||
const installing = computed(() => props.instance.install_stage !== 'installed')
|
||||
|
||||
const vintl = useVIntl()
|
||||
@@ -494,7 +513,7 @@ const messages = defineMessages({
|
||||
const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
|
||||
const options: FilterOption[] = []
|
||||
|
||||
const frequency = projects.value.reduce((map: Record<string, number>, item) => {
|
||||
const frequency = projects.value.reduce((map, item) => {
|
||||
map[item.project_type] = (map[item.project_type] || 0) + 1
|
||||
return map
|
||||
}, {})
|
||||
@@ -525,7 +544,7 @@ const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
|
||||
return options
|
||||
})
|
||||
|
||||
const selectedFilters = ref<string[]>([])
|
||||
const selectedFilters = ref([])
|
||||
const filteredProjects = computed(() => {
|
||||
const updatesFilter = selectedFilters.value.includes('updates')
|
||||
const disabledFilter = selectedFilters.value.includes('disabled')
|
||||
@@ -552,7 +571,7 @@ watch(filterOptions, () => {
|
||||
}
|
||||
})
|
||||
|
||||
function toggleArray<T>(array: T[], value: T) {
|
||||
function toggleArray(array, value) {
|
||||
if (array.includes(value)) {
|
||||
array.splice(array.indexOf(value), 1)
|
||||
} else {
|
||||
@@ -562,7 +581,7 @@ function toggleArray<T>(array: T[], value: T) {
|
||||
|
||||
const searchFilter = ref('')
|
||||
const selectAll = ref(false)
|
||||
const shareModal = ref<InstanceType<typeof ShareModalWrapper> | null>()
|
||||
const shareModal = ref(null)
|
||||
const ascending = ref(true)
|
||||
const sortColumn = ref('Name')
|
||||
const currentPage = ref(1)
|
||||
@@ -603,7 +622,7 @@ const search = computed(() => {
|
||||
|
||||
watch([sortColumn, ascending, selectedFilters.value, searchFilter], () => (currentPage.value = 1))
|
||||
|
||||
const sortProjects = (filter: string) => {
|
||||
const sortProjects = (filter) => {
|
||||
if (sortColumn.value === filter) {
|
||||
ascending.value = !ascending.value
|
||||
} else {
|
||||
@@ -621,7 +640,7 @@ const updateAll = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const paths = (await update_all(props.instance.path).catch(handleError)) as Record<string, string>
|
||||
const paths = await update_all(props.instance.path).catch(handleError)
|
||||
|
||||
for (const [oldVal, newVal] of Object.entries(paths)) {
|
||||
const index = projects.value.findIndex((x) => x.path === oldVal)
|
||||
@@ -630,7 +649,7 @@ const updateAll = async () => {
|
||||
|
||||
if (projects.value[index].updateVersion) {
|
||||
projects.value[index].version = projects.value[index].updateVersion.version_number
|
||||
projects.value[index].updateVersion = undefined
|
||||
projects.value[index].updateVersion = null
|
||||
}
|
||||
}
|
||||
for (const project of setProjects) {
|
||||
@@ -645,15 +664,15 @@ const updateAll = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
const updateProject = async (mod: ProjectListEntry) => {
|
||||
const updateProject = async (mod) => {
|
||||
mod.updating = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
mod.path = await update_project(props.instance.path, mod.path).catch(handleError)
|
||||
mod.updating = false
|
||||
|
||||
mod.outdated = false
|
||||
mod.version = mod.updateVersion?.version_number
|
||||
mod.updateVersion = undefined
|
||||
mod.version = mod.updateVersion.version_number
|
||||
mod.updateVersion = null
|
||||
|
||||
trackEvent('InstanceProjectUpdate', {
|
||||
loader: props.instance.loader,
|
||||
@@ -664,15 +683,15 @@ const updateProject = async (mod: ProjectListEntry) => {
|
||||
})
|
||||
}
|
||||
|
||||
const locks: Record<string, string | null> = {}
|
||||
const locks = {}
|
||||
|
||||
const toggleDisableMod = async (mod: ProjectListEntry) => {
|
||||
const toggleDisableMod = async (mod) => {
|
||||
// Use mod's id as the key for the lock. If mod doesn't have a unique id, replace `mod.id` with some unique property.
|
||||
const lock = locks[mod.file_name]
|
||||
|
||||
while (lock) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout((value: unknown) => resolve(value), 100)
|
||||
setTimeout((_) => resolve(), 100)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -697,20 +716,20 @@ const toggleDisableMod = async (mod: ProjectListEntry) => {
|
||||
locks[mod.file_name] = null
|
||||
}
|
||||
|
||||
const removeMod = async (mod: ContentItem<ProjectListEntry>) => {
|
||||
const removeMod = async (mod) => {
|
||||
await remove_project(props.instance.path, mod.path).catch(handleError)
|
||||
projects.value = projects.value.filter((x) => mod.path !== x.path)
|
||||
|
||||
trackEvent('InstanceProjectRemove', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
id: mod.data.id,
|
||||
name: mod.data.name,
|
||||
project_type: mod.data.project_type,
|
||||
id: mod.id,
|
||||
name: mod.name,
|
||||
project_type: mod.project_type,
|
||||
})
|
||||
}
|
||||
|
||||
const copyModLink = async (mod: ContentItem<ProjectListEntry>) => {
|
||||
const copyModLink = async (mod) => {
|
||||
await navigator.clipboard.writeText(
|
||||
`https://modrinth.com/${mod.data.project_type}/${mod.data.slug}`,
|
||||
)
|
||||
@@ -725,15 +744,15 @@ const deleteSelected = async () => {
|
||||
}
|
||||
|
||||
const shareNames = async () => {
|
||||
await shareModal.value?.show(functionValues.value.map((x) => x.name).join('\n'))
|
||||
await shareModal.value.show(functionValues.value.map((x) => x.name).join('\n'))
|
||||
}
|
||||
|
||||
const shareFileNames = async () => {
|
||||
await shareModal.value?.show(functionValues.value.map((x) => x.file_name).join('\n'))
|
||||
await shareModal.value.show(functionValues.value.map((x) => x.file_name).join('\n'))
|
||||
}
|
||||
|
||||
const shareUrls = async () => {
|
||||
await shareModal.value?.show(
|
||||
await shareModal.value.show(
|
||||
functionValues.value
|
||||
.filter((x) => x.slug)
|
||||
.map((x) => `https://modrinth.com/${x.project_type}/${x.slug}`)
|
||||
@@ -742,7 +761,7 @@ const shareUrls = async () => {
|
||||
}
|
||||
|
||||
const shareMarkdown = async () => {
|
||||
await shareModal.value?.show(
|
||||
await shareModal.value.show(
|
||||
functionValues.value
|
||||
.map((x) => {
|
||||
if (x.slug) {
|
||||
@@ -807,17 +826,15 @@ const unlisten = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
await initProjects()
|
||||
})
|
||||
|
||||
const unlistenProfiles = await profile_listener(
|
||||
async (event: { event: string; profile_path_id: string }) => {
|
||||
if (
|
||||
event.profile_path_id === props.instance.path &&
|
||||
event.event === 'synced' &&
|
||||
props.instance.install_stage !== 'pack_installing'
|
||||
) {
|
||||
await initProjects()
|
||||
}
|
||||
},
|
||||
)
|
||||
const unlistenProfiles = await profile_listener(async (event) => {
|
||||
if (
|
||||
event.profile_path_id === props.instance.path &&
|
||||
event.event === 'synced' &&
|
||||
props.instance.install_stage !== 'pack_installing'
|
||||
) {
|
||||
await initProjects()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlisten()
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<FilterBar v-model="filters" :options="filterOptions" show-all-options />
|
||||
<FilterBar v-model="filters" :options="filterOptions" />
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<WorldItem
|
||||
v-for="world in filteredWorlds"
|
||||
@@ -86,7 +86,6 @@
|
||||
world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world)
|
||||
"
|
||||
@delete="() => promptToRemoveWorld(world)"
|
||||
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,7 +151,6 @@ import {
|
||||
hasQuickPlaySupport,
|
||||
refreshWorlds,
|
||||
handleDefaultProfileUpdateEvent,
|
||||
showWorldInFolder,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
|
||||
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
||||
@@ -227,11 +225,6 @@ const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
|
||||
await refreshAllWorlds()
|
||||
|
||||
async function refreshServer(address: string) {
|
||||
if (!serverData.value[address]) {
|
||||
serverData.value[address] = {
|
||||
refreshing: true,
|
||||
}
|
||||
}
|
||||
await refreshServerData(serverData.value[address], protocolVersion.value, address)
|
||||
}
|
||||
|
||||
@@ -270,12 +263,9 @@ async function addServer(server: ServerWorld) {
|
||||
async function editServer(server: ServerWorld) {
|
||||
const index = worlds.value.findIndex((w) => w.type === 'server' && w.index === server.index)
|
||||
if (index !== -1) {
|
||||
const oldServer = worlds.value[index] as ServerWorld
|
||||
worlds.value[index] = server
|
||||
sortWorlds(worlds.value)
|
||||
if (oldServer.address !== server.address) {
|
||||
await refreshServer(server.address)
|
||||
}
|
||||
await refreshServer(server.address)
|
||||
} else {
|
||||
handleError(`Error refreshing server, refreshing all worlds`)
|
||||
await refreshAllWorlds()
|
||||
@@ -359,34 +349,26 @@ const supportsQuickPlay = computed(() =>
|
||||
const filterOptions = computed(() => {
|
||||
const options: FilterBarOption[] = []
|
||||
|
||||
const hasServer = worlds.value.some((x) => x.type === 'server')
|
||||
|
||||
if (worlds.value.some((x) => x.type === 'singleplayer') && hasServer) {
|
||||
if (worlds.value.some((x) => x.type === 'singleplayer')) {
|
||||
options.push({
|
||||
id: 'singleplayer',
|
||||
message: messages.singleplayer,
|
||||
})
|
||||
}
|
||||
|
||||
if (worlds.value.some((x) => x.type === 'server')) {
|
||||
options.push({
|
||||
id: 'server',
|
||||
message: messages.server,
|
||||
})
|
||||
}
|
||||
|
||||
if (hasServer) {
|
||||
// add available filter if there's any offline ("unavailable") servers AND there's any singleplayer worlds or available servers
|
||||
// add available filter if there's any offline ("unavailable") servers
|
||||
if (
|
||||
worlds.value.some(
|
||||
(x) =>
|
||||
x.type === 'server' &&
|
||||
!serverData.value[x.address]?.status &&
|
||||
!serverData.value[x.address]?.refreshing,
|
||||
) &&
|
||||
worlds.value.some(
|
||||
(x) =>
|
||||
x.type === 'singleplayer' ||
|
||||
(x.type === 'server' &&
|
||||
serverData.value[x.address]?.status &&
|
||||
!serverData.value[x.address]?.refreshing),
|
||||
)
|
||||
) {
|
||||
options.push({
|
||||
|
||||
@@ -155,7 +155,7 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, shallowRef, watch } from 'vue'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
@@ -170,7 +170,6 @@ import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -248,9 +247,6 @@ async function install(version) {
|
||||
installedVersion.value = version
|
||||
}
|
||||
},
|
||||
(profile) => {
|
||||
router.push(`/instance/${profile}`)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,14 +34,6 @@ export default new createRouter({
|
||||
breadcrumb: [{ name: 'Discover content' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/skins',
|
||||
name: 'Skins',
|
||||
component: Pages.Skins,
|
||||
meta: {
|
||||
breadcrumb: [{ name: 'Skins' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/library',
|
||||
name: 'Library',
|
||||
|
||||
@@ -23,8 +23,8 @@ export const useInstall = defineStore('installStore', {
|
||||
setInstallConfirmModal(ref) {
|
||||
this.installConfirmModal = ref
|
||||
},
|
||||
showInstallConfirmModal(project, version_id, onInstall, createInstanceCallback) {
|
||||
this.installConfirmModal.show(project, version_id, onInstall, createInstanceCallback)
|
||||
showInstallConfirmModal(project, version_id, onInstall) {
|
||||
this.installConfirmModal.show(project, version_id, onInstall)
|
||||
},
|
||||
setIncompatibilityWarningModal(ref) {
|
||||
this.incompatibilityWarningModal = ref
|
||||
@@ -41,14 +41,7 @@ export const useInstall = defineStore('installStore', {
|
||||
},
|
||||
})
|
||||
|
||||
export const install = async (
|
||||
projectId,
|
||||
versionId,
|
||||
instancePath,
|
||||
source,
|
||||
callback = () => {},
|
||||
createInstanceCallback = () => {},
|
||||
) => {
|
||||
export const install = async (projectId, versionId, instancePath, source, callback = () => {}) => {
|
||||
const project = await get_project(projectId, 'must_revalidate').catch(handleError)
|
||||
|
||||
if (project.project_type === 'modpack') {
|
||||
@@ -56,13 +49,7 @@ export const install = async (
|
||||
const packs = await list().catch(handleError)
|
||||
|
||||
if (packs.length === 0 || !packs.find((pack) => pack.linked_data?.project_id === project.id)) {
|
||||
await packInstall(
|
||||
project.id,
|
||||
version,
|
||||
project.title,
|
||||
project.icon_url,
|
||||
createInstanceCallback,
|
||||
).catch(handleError)
|
||||
await packInstall(project.id, version, project.title, project.icon_url).catch(handleError)
|
||||
|
||||
trackEvent('PackInstall', {
|
||||
id: project.id,
|
||||
@@ -74,7 +61,7 @@ export const install = async (
|
||||
callback(version)
|
||||
} else {
|
||||
const install = useInstall()
|
||||
install.showInstallConfirmModal(project, version, callback, createInstanceCallback)
|
||||
install.showInstallConfirmModal(project, version, callback)
|
||||
}
|
||||
} else {
|
||||
if (instancePath) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTheming } from './theme.ts'
|
||||
import { useTheming } from './theme'
|
||||
import { useBreadcrumbs } from './breadcrumbs'
|
||||
import { useLoading } from './loading'
|
||||
import { useNotifications, handleError } from './notifications'
|
||||
|
||||
38
apps/app-frontend/src/store/theme.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useTheming = defineStore('themeStore', {
|
||||
state: () => ({
|
||||
themeOptions: ['dark', 'light', 'oled', 'system'],
|
||||
advancedRendering: true,
|
||||
selectedTheme: 'dark',
|
||||
toggleSidebar: false,
|
||||
|
||||
devMode: false,
|
||||
featureFlags: {},
|
||||
}),
|
||||
actions: {
|
||||
setThemeState(newTheme) {
|
||||
if (this.themeOptions.includes(newTheme)) this.selectedTheme = newTheme
|
||||
else console.warn('Selected theme is not present. Check themeOptions.')
|
||||
|
||||
this.setThemeClass()
|
||||
},
|
||||
setThemeClass() {
|
||||
for (const theme of this.themeOptions) {
|
||||
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
|
||||
}
|
||||
|
||||
let theme = this.selectedTheme
|
||||
if (this.selectedTheme === 'system') {
|
||||
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
if (darkThemeMq.matches) {
|
||||
theme = 'dark'
|
||||
} else {
|
||||
theme = 'light'
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const DEFAULT_FEATURE_FLAGS = {
|
||||
project_background: false,
|
||||
page_path: false,
|
||||
worlds_tab: false,
|
||||
worlds_in_home: true,
|
||||
}
|
||||
|
||||
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
|
||||
|
||||
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||
export type FeatureFlags = Record<FeatureFlag, boolean>
|
||||
export type ColorTheme = (typeof THEME_OPTIONS)[number]
|
||||
|
||||
export type ThemeStore = {
|
||||
selectedTheme: ColorTheme
|
||||
advancedRendering: boolean
|
||||
toggleSidebar: boolean
|
||||
|
||||
devMode: boolean
|
||||
featureFlags: FeatureFlags
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_STORE: ThemeStore = {
|
||||
selectedTheme: 'dark',
|
||||
advancedRendering: true,
|
||||
toggleSidebar: false,
|
||||
|
||||
devMode: false,
|
||||
featureFlags: DEFAULT_FEATURE_FLAGS,
|
||||
}
|
||||
|
||||
export const useTheming = defineStore('themeStore', {
|
||||
state: () => DEFAULT_THEME_STORE,
|
||||
actions: {
|
||||
setThemeState(newTheme: ColorTheme) {
|
||||
if (THEME_OPTIONS.includes(newTheme)) {
|
||||
this.selectedTheme = newTheme
|
||||
} else {
|
||||
console.warn('Selected theme is not present. Check themeOptions.')
|
||||
}
|
||||
|
||||
this.setThemeClass()
|
||||
},
|
||||
setThemeClass() {
|
||||
for (const theme of THEME_OPTIONS) {
|
||||
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
|
||||
}
|
||||
|
||||
let theme = this.selectedTheme
|
||||
if (this.selectedTheme === 'system') {
|
||||
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
if (darkThemeMq.matches) {
|
||||
theme = 'dark'
|
||||
} else {
|
||||
theme = 'light'
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
|
||||
},
|
||||
getFeatureFlag(key: FeatureFlag) {
|
||||
return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
|
||||
},
|
||||
getThemeOptions() {
|
||||
return THEME_OPTIONS
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -41,7 +41,6 @@ export default {
|
||||
green: 'var(--color-green-highlight)',
|
||||
blue: 'var(--color-blue-highlight)',
|
||||
purple: 'var(--color-purple-highlight)',
|
||||
gray: 'var(--color-gray-highlight)',
|
||||
},
|
||||
divider: {
|
||||
DEFAULT: 'var(--color-divider)',
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
"strict": true
|
||||
},
|
||||
|
||||
@@ -4,8 +4,6 @@ import svgLoader from 'vite-svg-loader'
|
||||
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
import tauriConf from '../app/tauri.conf.json'
|
||||
|
||||
const projectRootDir = resolve(__dirname)
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
@@ -43,32 +41,17 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
headers: {
|
||||
'content-security-policy': Object.entries(tauriConf.app.security.csp)
|
||||
.map(([directive, sources]) => {
|
||||
// An additional websocket connect-src is required for Vite dev tools to work
|
||||
if (directive === 'connect-src') {
|
||||
sources = Array.isArray(sources) ? sources : [sources]
|
||||
sources.push('ws://localhost:1420')
|
||||
}
|
||||
|
||||
return Array.isArray(sources)
|
||||
? `${directive} ${sources.join(' ')}`
|
||||
: `${directive} ${sources}`
|
||||
})
|
||||
.join('; '),
|
||||
},
|
||||
},
|
||||
// to make use of `TAURI_ENV_DEBUG` and other env variables
|
||||
// https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
build: {
|
||||
// Tauri supports es2021
|
||||
target: process.env.TAURI_ENV_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
// don't minify for debug builds
|
||||
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
// produce sourcemaps for debug builds
|
||||
sourcemap: !!process.env.TAURI_ENV_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
sourcemap: !!process.env.TAURI_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
|
||||
commonjsOptions: {
|
||||
esmExternals: true,
|
||||
},
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
[package]
|
||||
name = "theseus_playground"
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
theseus = { workspace = true, features = ["cli"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
enumset.workspace = true
|
||||
theseus = { path = "../../packages/app-lib", features = ["cli"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
webbrowser = "0.8.13"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
tracing = "0.1.37"
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
"name": "@modrinth/app-playground",
|
||||
"scripts": {
|
||||
"build": "cargo build --release",
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets",
|
||||
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt",
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings",
|
||||
"fix": "cargo fmt && cargo clippy --fix",
|
||||
"dev": "cargo run",
|
||||
"test": "cargo nextest run --all-targets --no-fail-fast"
|
||||
"test": "cargo test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
use enumset::EnumSet;
|
||||
use theseus::prelude::*;
|
||||
use theseus::worlds::get_recent_worlds;
|
||||
|
||||
@@ -15,7 +14,8 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
||||
println!("A browser window will now open, follow the login flow there.");
|
||||
let login = minecraft_auth::begin_login().await?;
|
||||
|
||||
println!("Open URL {} in a browser", login.redirect_uri.as_str());
|
||||
println!("URL {}", login.redirect_uri.as_str());
|
||||
webbrowser::open(login.redirect_uri.as_str())?;
|
||||
|
||||
println!("Please enter URL code: ");
|
||||
let mut input = String::new();
|
||||
@@ -27,10 +27,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
||||
|
||||
let credentials = minecraft_auth::finish_login(&input, login).await?;
|
||||
|
||||
println!(
|
||||
"Logged in user {}.",
|
||||
credentials.maybe_online_profile().await.name
|
||||
);
|
||||
println!("Logged in user {}.", credentials.username);
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
@@ -43,7 +40,7 @@ async fn main() -> theseus::Result<()> {
|
||||
// Initialize state
|
||||
State::init().await?;
|
||||
|
||||
let worlds = get_recent_worlds(4, EnumSet::all()).await?;
|
||||
let worlds = get_recent_worlds(4).await?;
|
||||
for world in worlds {
|
||||
println!(
|
||||
"World: {:?}/{:?} played at {:?}: {:#?}",
|
||||
|
||||
4
apps/app/.gitignore
vendored
@@ -1,2 +1,6 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by tauri, metadata generated at compile time
|
||||
/gen/
|
||||
|
||||
@@ -1,52 +1,62 @@
|
||||
[package]
|
||||
name = "theseus_gui"
|
||||
version = "0.10.1"
|
||||
version = "0.9.4"
|
||||
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/"
|
||||
edition.workspace = true
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { workspace = true, features = ["codegen"] }
|
||||
tauri-build = { version = "2.0.3", features = ["codegen"] }
|
||||
|
||||
[dependencies]
|
||||
theseus = { workspace = true, features = ["tauri"] }
|
||||
theseus = { path = "../../packages/app-lib", features = ["tauri"] }
|
||||
|
||||
serde_json.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_with.workspace = true
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_with = "3.0.0"
|
||||
|
||||
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] }
|
||||
tauri-plugin-deep-link.workspace = true
|
||||
tauri-plugin-dialog.workspace = true
|
||||
tauri-plugin-http.workspace = true
|
||||
tauri-plugin-opener.workspace = true
|
||||
tauri-plugin-os.workspace = true
|
||||
tauri-plugin-single-instance.workspace = true
|
||||
tauri-plugin-updater.workspace = true
|
||||
tauri-plugin-window-state.workspace = true
|
||||
tauri = { version = "2.1.1", features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] }
|
||||
tauri-plugin-window-state = "2.2.0"
|
||||
tauri-plugin-deep-link = "2.2.0"
|
||||
tauri-plugin-os = "2.2.0"
|
||||
tauri-plugin-opener = "2.2.1"
|
||||
tauri-plugin-dialog = "2.2.0"
|
||||
tauri-plugin-updater = { version = "2.3.0" }
|
||||
tauri-plugin-single-instance = { version = "2.2.0" }
|
||||
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
thiserror.workspace = true
|
||||
daedalus.workspace = true
|
||||
chrono.workspace = true
|
||||
either.workspace = true
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
thiserror = "1.0"
|
||||
daedalus = { path = "../../packages/daedalus" }
|
||||
chrono = "0.4.26"
|
||||
either = "1.15"
|
||||
|
||||
url.workspace = true
|
||||
urlencoding.workspace = true
|
||||
uuid = { workspace = true, features = ["serde", "v4"] }
|
||||
url = "2.2"
|
||||
urlencoding = "2.1"
|
||||
uuid = { version = "1.1", features = ["serde", "v4"] }
|
||||
os_info = "3.7.0"
|
||||
|
||||
tracing.workspace = true
|
||||
tracing-error.workspace = true
|
||||
tracing = "0.1.37"
|
||||
tracing-error = "0.2.0"
|
||||
|
||||
dashmap.workspace = true
|
||||
paste.workspace = true
|
||||
enumset = { workspace = true, features = ["serde"] }
|
||||
dashmap = "6.0.1"
|
||||
paste = "1.0.15"
|
||||
|
||||
native-dialog.workspace = true
|
||||
opener = { version = "0.7.2", features = ["reveal", "dbus-vendored"] }
|
||||
|
||||
native-dialog = "0.7.0"
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
window-shadows = "0.2.1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cocoa = "0.25.0"
|
||||
objc = "0.2.7"
|
||||
rand = "0.8.5"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
tauri-plugin-updater = { workspace = true, optional = true }
|
||||
tauri-plugin-updater = { version = "2.3.0", optional = true, features = ["native-tls-vendored", "zip"], default-features = false }
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
@@ -56,6 +66,3 @@ default = ["custom-protocol"]
|
||||
# DO NOT remove this
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
updater = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -99,24 +99,6 @@ fn main() {
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
),
|
||||
)
|
||||
.plugin(
|
||||
"minecraft-skins",
|
||||
InlinedPlugin::new()
|
||||
.commands(&[
|
||||
"get_available_capes",
|
||||
"get_available_skins",
|
||||
"add_and_equip_custom_skin",
|
||||
"set_default_cape",
|
||||
"equip_skin",
|
||||
"remove_custom_skin",
|
||||
"unequip_skin",
|
||||
"normalize_skin_texture",
|
||||
"get_dragged_skin_data",
|
||||
])
|
||||
.default_permission(
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
),
|
||||
)
|
||||
.plugin(
|
||||
"mr-auth",
|
||||
InlinedPlugin::new()
|
||||
@@ -169,6 +151,7 @@ fn main() {
|
||||
"profile_update_managed_modrinth_version",
|
||||
"profile_repair_managed_modrinth",
|
||||
"profile_run",
|
||||
"profile_run_credentials",
|
||||
"profile_kill",
|
||||
"profile_edit",
|
||||
"profile_edit_icon",
|
||||
@@ -265,7 +248,6 @@ fn main() {
|
||||
"get_recent_worlds",
|
||||
"get_profile_worlds",
|
||||
"get_singleplayer_world",
|
||||
"set_world_display_status",
|
||||
"rename_world",
|
||||
"reset_world_icon",
|
||||
"backup_world",
|
||||
|
||||
@@ -19,21 +19,12 @@
|
||||
"window-state:default",
|
||||
"window-state:allow-restore-state",
|
||||
"window-state:allow-save-window-state",
|
||||
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "https://modrinth.com/*" },
|
||||
{ "url": "https://*.modrinth.com/*" }
|
||||
]
|
||||
},
|
||||
|
||||
"auth:default",
|
||||
"import:default",
|
||||
"jre:default",
|
||||
"logs:default",
|
||||
"metadata:default",
|
||||
"minecraft-skins:default",
|
||||
"mr-auth:default",
|
||||
"profile-create:default",
|
||||
"pack:default",
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |