You've already forked AstralRinth
Compare commits
139 Commits
AR-0.10.3
...
8d36c14554
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d36c14554 | |||
|
|
6387fb21c6 | ||
|
|
c7d0839bfb | ||
| 2b43e26a85 | |||
|
|
175b90be5a | ||
|
|
13103b4950 | ||
|
|
8804478221 | ||
|
|
b8982a6d17 | ||
|
|
ff88724d01 | ||
|
|
7dffb352d5 | ||
|
|
1df6e29aa1 | ||
|
|
5deb4179ad | ||
|
|
358cf31c87 | ||
| 7cea4b21a8 | |||
| 7846fd00aa | |||
| cebc195fe0 | |||
|
|
6db1d66591 | ||
|
|
8052fda840 | ||
| ae58f3844d | |||
| acd4b1696a | |||
| 5ea78b78c2 | |||
| f90998157d | |||
| 634000cdb6 | |||
|
|
5fd8c38c1c | ||
|
|
15892a88d3 | ||
|
|
32793c50e1 | ||
|
|
0e0ca1971a | ||
|
|
bb9af18eed | ||
|
|
d4516d3527 | ||
|
|
87de47fe5e | ||
|
|
7d76fe1b6a | ||
| 46d30e491a | |||
| 059c0618f1 | |||
| 7ef60fcafe | |||
| ec17e79014 | |||
| e351d674f4 | |||
| f555fa916a | |||
| dbe38cb4e7 | |||
| 2e40e26116 | |||
|
|
ae25a15abd | ||
|
|
0f755b94ce | ||
|
|
bcf46d440b | ||
|
|
526561f2de | ||
|
|
a8caa1afc3 | ||
|
|
98e9a8473d | ||
|
|
936395484e | ||
|
|
0c3e23db96 | ||
|
|
013ba4d86d | ||
|
|
93813c448c | ||
|
|
c20b869e62 | ||
|
|
56c556821b | ||
|
|
44267619b6 | ||
| 10afd673db | |||
|
|
90043fe84d | ||
|
|
a6a98ff63e | ||
|
|
911652133b | ||
|
|
cee1b5f522 | ||
|
|
62f5a23fcb | ||
| 5a10292add | |||
| 3f606a08aa | |||
| 2d5d747202 | |||
|
|
eb595cdc3e | ||
| 7516ff9e47 | |||
|
|
572cd065ed | ||
| df9bbe3ba0 | |||
| 362fd7f32a | |||
|
|
76dc8a0897 | ||
|
|
4723de6269 | ||
|
|
e15fa35bad | ||
| 7716a0c524 | |||
|
|
2cc6bc8ce4 | ||
|
|
5d19d31b2c | ||
|
|
c1b95ede07 | ||
|
|
058185c7fd | ||
|
|
6fb125cf0f | ||
|
|
a945e9b005 | ||
|
|
b943638afb | ||
|
|
207dc0e2bb | ||
|
|
359fbd4738 | ||
| adf831dab9 | |||
| efeac22d14 | |||
| 591d98a9eb | |||
| 77472d9a09 | |||
| 789d666515 | |||
| d917bff6ef | |||
| 4e69cd8bde | |||
| b71e4cc6f9 | |||
| a56ab6adb9 | |||
| f1b67c9584 | |||
| 3d32640b83 | |||
| 6dfb599e14 | |||
| 332a543f66 | |||
| 1ef96c447e | |||
| 1ec92b5f97 | |||
| f0a4532051 | |||
|
|
f7700acce4 | ||
|
|
87a3e2d022 | ||
|
|
5d17663040 | ||
|
|
cff3c72f94 | ||
|
|
fadf475f06 | ||
|
|
7228499737 | ||
|
|
bca467a634 | ||
| 14f6450cf4 | |||
| 14bf06e4bd | |||
|
|
cb72d2ac80 | ||
|
|
3c79607d1f | ||
| 97bd18c7b3 | |||
| 8af0288274 | |||
| 167072de0c | |||
| 2df37be9a7 | |||
| 34d85a03b2 | |||
| 17cf5e3132 | |||
|
|
36ad1f16e4 | ||
|
|
5d4f334505 | ||
|
|
1fdb5ba748 | ||
| c5e67a5c6f | |||
| e2e21c1496 | |||
| 6da942ccbb | |||
|
|
26df6f51ef | ||
|
|
6caf794ae1 | ||
|
|
2692953e31 | ||
|
|
242fd713ab | ||
|
|
7a12c4d5e2 | ||
| 0ab4dec62d | |||
|
|
f256ef43c0 | ||
| 3ecb20afd6 | |||
| 1e10f24efe | |||
| 006fd7c7f5 | |||
| 1e8e001eb8 | |||
| 585935c799 | |||
|
|
e0cde2d6ff | ||
| a64c3360d2 | |||
|
|
e4e77dc0d2 | ||
|
|
8ba6467f21 | ||
| a2b2711204 | |||
|
|
088cb54317 | ||
| ab57926e44 | |||
| 35cd79727a | |||
|
|
c47bcf665d |
@@ -2,5 +2,8 @@
|
|||||||
[target.'cfg(windows)']
|
[target.'cfg(windows)']
|
||||||
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
|
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
|
||||||
|
|
||||||
|
[target.x86_64-pc-windows-msvc]
|
||||||
|
linker = "rust-lld"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
rustflags = ["--cfg", "tokio_unstable"]
|
rustflags = ["--cfg", "tokio_unstable"]
|
||||||
|
|||||||
1
.dockerignore
Symbolic link
1
.dockerignore
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
.gitignore
|
||||||
56
.github/workflows/astralrinth-build.yml
vendored
56
.github/workflows/astralrinth-build.yml
vendored
@@ -16,6 +16,7 @@ on:
|
|||||||
- 'packages/assets/**'
|
- 'packages/assets/**'
|
||||||
- 'packages/ui/**'
|
- 'packages/ui/**'
|
||||||
- 'packages/utils/**'
|
- 'packages/utils/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -23,10 +24,11 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
platform: [macos-latest, windows-latest, ubuntu-latest]
|
# platform: [macos-latest, windows-latest, ubuntu-latest]
|
||||||
|
platform: [windows-latest, ubuntu-latest]
|
||||||
include:
|
include:
|
||||||
- platform: macos-latest
|
# - platform: macos-latest
|
||||||
artifact-target-name: universal-apple-darwin
|
# artifact-target-name: universal-apple-darwin
|
||||||
- platform: windows-latest
|
- platform: windows-latest
|
||||||
artifact-target-name: x86_64-pc-windows-msvc
|
artifact-target-name: x86_64-pc-windows-msvc
|
||||||
- platform: ubuntu-latest
|
- platform: ubuntu-latest
|
||||||
@@ -40,6 +42,35 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: 🔍 Validate Git config does not introduce CRLF
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "🔍 Checking Git config for CRLF settings..."
|
||||||
|
|
||||||
|
autocrlf=$(git config --get core.autocrlf || echo "unset")
|
||||||
|
eol_setting=$(git config --get core.eol || echo "unset")
|
||||||
|
|
||||||
|
echo "core.autocrlf = $autocrlf"
|
||||||
|
echo "core.eol = $eol_setting"
|
||||||
|
|
||||||
|
if [ "$autocrlf" = "true" ]; then
|
||||||
|
echo "⚠️ WARNING: core.autocrlf is set to 'true'. Consider setting it to 'input' or 'false'."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$eol_setting" = "crlf" ]; then
|
||||||
|
echo "⚠️ WARNING: core.eol is set to 'crlf'. Consider unsetting it or setting to 'lf'."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 🔍 Check migration files line endings (LF only)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "🔍 Scanning migration SQL files for CR characters (\\r)..."
|
||||||
|
if grep -Iq $'\r' packages/app-lib/migrations/*.sql; then
|
||||||
|
echo "❌ ERROR: Some migration files contain CR (\\r) characters — expected only LF line endings."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ All migration files use LF line endings"
|
||||||
|
|
||||||
- name: 🧰 Setup Rust toolchain
|
- name: 🧰 Setup Rust toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
with:
|
with:
|
||||||
@@ -65,6 +96,11 @@ jobs:
|
|||||||
librsvg2-dev \
|
librsvg2-dev \
|
||||||
xdg-utils \
|
xdg-utils \
|
||||||
openjdk-11-jdk
|
openjdk-11-jdk
|
||||||
|
|
||||||
|
- name: ⚙️ Set application environment
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cp packages/app-lib/.env.prod packages/app-lib/.env
|
||||||
|
|
||||||
- name: 💨 Setup Turbo cache
|
- name: 💨 Setup Turbo cache
|
||||||
uses: rharkor/caching-for-turbo@v1.8
|
uses: rharkor/caching-for-turbo@v1.8
|
||||||
@@ -73,7 +109,7 @@ jobs:
|
|||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- name: ✍️ Set up Windows code signing (jsign)
|
- name: ✍️ Set up Windows code signing (jsign)
|
||||||
if: matrix.platform == 'windows' && env.SIGN_WINDOWS_BINARIES == 'true'
|
if: matrix.platform == 'windows-latest' && env.SIGN_WINDOWS_BINARIES == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
choco install jsign --ignore-dependencies
|
choco install jsign --ignore-dependencies
|
||||||
@@ -84,12 +120,12 @@ jobs:
|
|||||||
rm -rf target/release/bundle
|
rm -rf target/release/bundle
|
||||||
rm -rf target/*/release/bundle || true
|
rm -rf target/*/release/bundle || true
|
||||||
|
|
||||||
- name: 🔨 Build macOS app
|
# - name: 🔨 Build macOS app
|
||||||
if: matrix.platform == 'macos-latest'
|
# if: matrix.platform == 'macos-latest'
|
||||||
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
|
||||||
env:
|
# env:
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
# TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: 🔨 Build Linux app
|
- name: 🔨 Build Linux app
|
||||||
if: matrix.platform == 'ubuntu-latest'
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
|
|||||||
758
Cargo.lock
generated
758
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -67,7 +67,13 @@ heck = "0.5.0"
|
|||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
hickory-resolver = "0.25.2"
|
hickory-resolver = "0.25.2"
|
||||||
hmac = "0.12.1"
|
hmac = "0.12.1"
|
||||||
hyper-tls = "0.6.0"
|
hyper = "1.6.0"
|
||||||
|
hyper-rustls = { version = "0.27.7", default-features = false, features = [
|
||||||
|
"http1",
|
||||||
|
"native-tokio",
|
||||||
|
"ring",
|
||||||
|
"tls12",
|
||||||
|
] }
|
||||||
hyper-util = "0.1.14"
|
hyper-util = "0.1.14"
|
||||||
iana-time-zone = "0.1.63"
|
iana-time-zone = "0.1.63"
|
||||||
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
|
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
|
||||||
@@ -93,6 +99,7 @@ notify = { version = "8.0.0", default-features = false }
|
|||||||
notify-debouncer-mini = { version = "0.6.0", default-features = false }
|
notify-debouncer-mini = { version = "0.6.0", default-features = false }
|
||||||
p256 = "0.13.2"
|
p256 = "0.13.2"
|
||||||
paste = "1.0.15"
|
paste = "1.0.15"
|
||||||
|
phf = { version = "0.12.1", features = ["macros"] }
|
||||||
png = "0.17.16"
|
png = "0.17.16"
|
||||||
prometheus = "0.14.0"
|
prometheus = "0.14.0"
|
||||||
quartz_nbt = "0.2.9"
|
quartz_nbt = "0.2.9"
|
||||||
|
|||||||
159
README.md
159
README.md
@@ -1,76 +1,123 @@
|
|||||||
# Navigation in this README
|
# 📘 Navigation
|
||||||
- [Install instructions](#install-instructions)
|
|
||||||
- [Features](#features)
|
- [🔧 Install Instructions](#install-instructions)
|
||||||
- [Getting started](#getting-started)
|
- [✨ Features](#features)
|
||||||
- [Disclaimer](#disclaimer)
|
- [🚀 Getting Started](#getting-started)
|
||||||
- [Donate](#support-our-project-crypto-wallets)
|
- [⚠️ Disclaimer](#disclaimer)
|
||||||
|
- [💰 Donate](#support-our-project-crypto-wallets)
|
||||||
|
|
||||||
|
## Other languages
|
||||||
|
> [Русский](readme/ru_ru/README.md)
|
||||||
|
|
||||||
|
## Support channel
|
||||||
|
> [Telegram](https://me.astralium.su/ref/telegram_channel)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# About Project
|
# About Project
|
||||||
|
|
||||||
## AstralRinth • Empowering Your Minecraft Adventure
|
## **AstralRinth • Empowering Your Minecraft Adventure**
|
||||||
Welcome to AR • Fork of Modrinth, the ultimate game launcher designed to enhance your Minecraft experience through the Modrinth platform and their API. Whether you're a graphical interface enthusiast, or a developer integrating Modrinth projects, Theseus core is your gateway to a new level of Minecraft gaming.
|
|
||||||
|
|
||||||
## About Software
|
Welcome to **AstralRinth (AR)** — a powerful fork of Modrinth, reimagined to enhance your Minecraft journey. Whether you're a GUI enthusiast or a developer building with Modrinth’s API, **Theseus Core** is your launchpad into a new era of Minecraft gameplay.
|
||||||
Introducing AstralRinth, a specialized variant of Theseus dedicated to implementing offline authorization for an even more flexible and user-centric Minecraft Modrinth experience. Roam the Minecraft realms without the constraints of online authentication, thanks to AstralRinth.
|
|
||||||
|
|
||||||
## AR • Unlocking Minecraft's Boundless Horizon
|
- *Recently, improved integration with the Git Astralium API has been added.*
|
||||||
Dive into the extraordinary world of AstralRinth, a fork of the original project with a unique focus on providing a free trial experience for Minecraft, all without the need for a license. Currently boasting:
|
|
||||||
|
|
||||||
# Install instructions
|
## **About the Software**
|
||||||
- To install our application, you need to download a file for your operating system from our available releases or development builds • [Download variants here](https://git.astralium.su/didirus/AstralRinth/releases)
|
|
||||||
- After you have downloaded the required executable file or archive, then open it
|
|
||||||
|
|
||||||
### Downloadable file extensions
|
**AstralRinth** is a dedicated branch of the Theseus project, focused on **offline authentication**, offering you more flexibility and control. Play Minecraft without the need for constant online verification — a user-first approach to modern modded gaming.
|
||||||
- `.msi` format for Windows OS system _(Supported popular latest versions of Microsoft Windows)_
|
|
||||||
- `.dmg` format for MacOS system _(Works on Macos Ventura / Sonoma / Sequoia, but it should be works on older OS builds)_
|
|
||||||
- `.deb` format for Linux OS systems _(Since there are quite a few distributions, we do not guarantee
|
|
||||||
|
|
||||||
### Installation subjects
|
## **AR • Unlocking Minecraft's Boundless Horizon**
|
||||||
- Builds in releases that are signed with the following prefixes are not recommended for installation and may contain errors:
|
|
||||||
- `dev`
|
This unique fork introduces a **free trial Minecraft experience**, bypassing license checks while maintaining rich functionality. Currently includes:
|
||||||
- `nightly`
|
|
||||||
- `dirty`
|
---
|
||||||
- `dirty-dev`
|
|
||||||
- `dirty-nightly`
|
# Install Instructions
|
||||||
- `dirty_dev`
|
|
||||||
- `dirty_nightly`
|
To install the launcher:
|
||||||
- Auto-updating takes place through parsing special versions from releases, so we also distribute clean types of `.msi, .dmg and .deb`
|
|
||||||
|
1. Visit the [releases page](https://git.astralium.su/didirus/AstralRinth/releases) to download the correct version for your system.
|
||||||
|
2. Run the downloaded file or extract and launch it, depending on the format.
|
||||||
|
|
||||||
|
### Downloadable File Extensions
|
||||||
|
|
||||||
|
| Extension | OS | Notes |
|
||||||
|
| --------- | ------- | --------------------------------------------------------------------- |
|
||||||
|
| `.msi` | Windows | Supported on all recent Windows versions |
|
||||||
|
| `.dmg` | macOS | Works on Ventura, Sonoma, Sequoia _(may also support older versions)_ |
|
||||||
|
| `.deb` | Linux | Basic support; compatibility may vary by distribution |
|
||||||
|
|
||||||
|
### Installation Warnings
|
||||||
|
|
||||||
|
Avoid using builds with these prefixes — they may be unstable or experimental:
|
||||||
|
|
||||||
|
- `dev`
|
||||||
|
- `nightly`
|
||||||
|
- `dirty`
|
||||||
|
- `dirty-dev`
|
||||||
|
- `dirty-nightly`
|
||||||
|
- `dirty_dev`
|
||||||
|
- `dirty_nightly`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
|
|
||||||
### Featured enhancement in AR
|
> _The launcher provides an opportunity to use the well-known Modrinth, but with an improved user experience._
|
||||||
- AstralRinth offers a range of authorization options, giving users the flexibility to log in with valid licenses or even a pirate account without auth credentials breaks (_Unlike MultiMC Cracked and similar software_). Experience Minecraft on your terms, breaking free from traditional licensing constraints (_Popular in Russian Federation_).
|
|
||||||
|
|
||||||
### Easy to use
|
## Included exclusive features
|
||||||
- Using the launcher is intuitive, any user can figure it out.
|
|
||||||
|
|
||||||
### Update notifies
|
- No ads in the entire launcher.
|
||||||
- We have implemented notifications about the release of new updates on our Git. The launcher can also download them for you and try to install them.
|
- Custom `.svg` vector icons for a distinct UI.
|
||||||
|
- Improved compatibility with both licensed and pirate accounts.
|
||||||
|
- Use **official microsoft accounts** or **offline/pirate accounts** — login won't break.
|
||||||
|
- Supports license-free access for testing or personal use.
|
||||||
|
- No dependence on official authentication services.
|
||||||
|
- Discord Rich Presence integration:
|
||||||
|
- Dynamic status messages.
|
||||||
|
- In-game timer and AFK counter.
|
||||||
|
- Strict disabling of statistics and other Modrinth metrics.
|
||||||
|
- Optimized archive/package size.
|
||||||
|
- Integrated update fetcher for seamless version management.
|
||||||
|
- Built-in update alerts for new versions posted on Git Astralium.
|
||||||
|
- Automatic download and installation capabilities.
|
||||||
|
- Database migration fixes, when error occurred (Interactive Mode) (Modrinth issue)
|
||||||
|
- ElyBy skin system integration (AuthLib / Java)
|
||||||
|
|
||||||
### Enhancements
|
---
|
||||||
- Custom .SVG vectors for a personalized touch.
|
|
||||||
- Improved compatibility for both pirate and licensed accounts.
|
|
||||||
- Beautiful Discord RPC with random messages while playing, along with an in-game timer and AFK counter.
|
|
||||||
- Forced disabling of statistics collection (modrinch metrics) with a hard patch from AstralRinth, ensuring it remains deactivated regardless of the configuration setting.
|
|
||||||
- Removal of advertisements from all launcher views.
|
|
||||||
- Optimization of packages (archives).
|
|
||||||
- Integrated update fetching feature
|
|
||||||
|
|
||||||
# Getting Started
|
# Getting Started
|
||||||
To begin your AstralRinth adventure, follow these steps:
|
|
||||||
1. **Download Your OS Version**: Head over to our [releases page](https://git.astralium.su/didirus/AstralRinth/releases/) to find the right file for your operating system.
|
To begin using AstralRinth:
|
||||||
- **Choosing the Correct File**: Ensure you select the file that matches your OS requirements.
|
|
||||||
- [**How select file**](#downloadable-file-extensions)
|
1. **Download Your OS Version**
|
||||||
- [**How select release**](#installation-subjects)
|
|
||||||
2. **Authentication**: Log in with a valid license or, for testing, try using a pirate account to see AstralRinth in action.
|
- Go to the [releases page](https://git.astralium.su/didirus/AstralRinth/releases)
|
||||||
3. **Launch Minecraft**: Start your journey by launching Minecraft through AstralRinth and enjoy the adventures that await.
|
- [How to choose a file](#downloadable-file-extensions)
|
||||||
- **Choosing java installation**: The launcher will try to automatically detect the recommended JVM version for running the game, but you can configure everything in the launcher settings.
|
- [How to choose a release](#installation-warnings)
|
||||||
|
|
||||||
|
2. **Log In**
|
||||||
|
|
||||||
|
- Use your official Mojang/Microsoft account, or test using a non-licensed account.
|
||||||
|
|
||||||
|
3. **Launch Minecraft**
|
||||||
|
- Start Minecraft from the launcher.
|
||||||
|
- The launcher will auto-detect the recommended JVM version.
|
||||||
|
- You can also configure Java manually in the settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Disclaimer
|
# Disclaimer
|
||||||
- AstralRinth is a project intended for experimentation and educational purposes only. It does not endorse or support piracy, and users are encouraged to obtain valid licenses for a fully-supported Minecraft experience.
|
|
||||||
- Users are reminded to respect licensing agreements and support the developers of Minecraft.
|
|
||||||
|
|
||||||
# Support our Project (Crypto Wallets)
|
- **AstralRinth** is intended **solely for educational and experimental use**.
|
||||||
|
- We **do not condone piracy** — users are encouraged to purchase a legitimate Minecraft license.
|
||||||
|
- Respect all relevant licensing agreements and support Minecraft developers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Support Our Project (Crypto Wallets)
|
||||||
|
|
||||||
|
If you'd like to support development, you can donate via the following crypto wallets:
|
||||||
|
|
||||||
- BTC (Telegram): 14g6asNYzcUoaQtB8B2QGKabgEvn55wfLj
|
- BTC (Telegram): 14g6asNYzcUoaQtB8B2QGKabgEvn55wfLj
|
||||||
- USDT TRC20 (Telegram): TMSmv1D5Fdf4fipUpwBCdh16WevrV45vGr
|
- TONCOIN (Telegram): UQAqUJ2_hVBI6k_gPyfp_jd-1K0OS61nIFPZuJWN9BwGAvKe
|
||||||
- TONCOIN (Telegram): UQAqUJ2_hVBI6k_gPyfp_jd-1K0OS61nIFPZuJWN9BwGAvKe
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
|
|||||||
import { handleError, useNotifications } from '@/store/notifications.js'
|
import { handleError, useNotifications } from '@/store/notifications.js'
|
||||||
import { command_listener, warning_listener } from '@/helpers/events.js'
|
import { command_listener, warning_listener } from '@/helpers/events.js'
|
||||||
import { type } from '@tauri-apps/plugin-os'
|
import { type } from '@tauri-apps/plugin-os'
|
||||||
import { getOS, isDev, restartApp } from '@/helpers/utils.js'
|
import { getOS, isDev } from '@/helpers/utils.js'
|
||||||
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
|
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
import { getVersion } from '@tauri-apps/api/app'
|
import { getVersion } from '@tauri-apps/api/app'
|
||||||
@@ -61,9 +61,10 @@ import { renderString } from '@modrinth/utils'
|
|||||||
import { useFetch } from '@/helpers/fetch.js'
|
import { useFetch } from '@/helpers/fetch.js'
|
||||||
// import { check } from '@tauri-apps/plugin-updater'
|
// import { check } from '@tauri-apps/plugin-updater'
|
||||||
import NavButton from '@/components/ui/NavButton.vue'
|
import NavButton from '@/components/ui/NavButton.vue'
|
||||||
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||||
import { get_user } from '@/helpers/cache.js'
|
import { get_user } from '@/helpers/cache.js'
|
||||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||||
|
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
||||||
// import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
// import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||||
// import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
|
// import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
|
||||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||||
@@ -72,6 +73,9 @@ import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
|||||||
import { get_available_capes, get_available_skins } from './helpers/skins'
|
import { get_available_capes, get_available_skins } from './helpers/skins'
|
||||||
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
||||||
|
|
||||||
|
// [AR] Feature
|
||||||
|
import { getRemote, updateState } from '@/helpers/update.js'
|
||||||
|
|
||||||
const themeStore = useTheming()
|
const themeStore = useTheming()
|
||||||
|
|
||||||
const news = ref([])
|
const news = ref([])
|
||||||
@@ -99,6 +103,7 @@ const isMaximized = ref(false)
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await useCheckDisableMouseover()
|
await useCheckDisableMouseover()
|
||||||
|
await getRemote(false) // [AR] Check for updates
|
||||||
|
|
||||||
document.querySelector('body').addEventListener('click', handleClick)
|
document.querySelector('body').addEventListener('click', handleClick)
|
||||||
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
|
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
|
||||||
@@ -161,11 +166,11 @@ async function setupApp() {
|
|||||||
|
|
||||||
initAnalytics()
|
initAnalytics()
|
||||||
if (!telemetry) {
|
if (!telemetry) {
|
||||||
console.info("[AR] Telemetry disabled by default (Hard patched).")
|
console.info("[AR] • Telemetry disabled by default (Hard patched).")
|
||||||
optOutAnalytics()
|
optOutAnalytics()
|
||||||
}
|
}
|
||||||
if (!personalized_ads) {
|
if (!personalized_ads) {
|
||||||
console.info("[AR] Personalized ads disabled by default (Hard patched).")
|
console.info("[AR] • Personalized ads disabled by default (Hard patched).")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dev) debugAnalytics()
|
if (dev) debugAnalytics()
|
||||||
@@ -188,7 +193,7 @@ async function setupApp() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Patched by AstralRinth
|
/// [AR] Patch
|
||||||
// useFetch(
|
// useFetch(
|
||||||
// `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
// `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||||
// 'criticalAnnouncements',
|
// 'criticalAnnouncements',
|
||||||
@@ -279,6 +284,8 @@ const incompatibilityWarningModal = ref()
|
|||||||
|
|
||||||
const credentials = ref()
|
const credentials = ref()
|
||||||
|
|
||||||
|
const modrinthLoginFlowWaitModal = ref()
|
||||||
|
|
||||||
async function fetchCredentials() {
|
async function fetchCredentials() {
|
||||||
const creds = await getCreds().catch(handleError)
|
const creds = await getCreds().catch(handleError)
|
||||||
if (creds && creds.user_id) {
|
if (creds && creds.user_id) {
|
||||||
@@ -288,8 +295,24 @@ async function fetchCredentials() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function signIn() {
|
async function signIn() {
|
||||||
await login().catch(handleError)
|
modrinthLoginFlowWaitModal.value.show()
|
||||||
await fetchCredentials()
|
|
||||||
|
try {
|
||||||
|
await login()
|
||||||
|
await fetchCredentials()
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
typeof error['message'] === 'string' &&
|
||||||
|
error.message.includes('Login canceled')
|
||||||
|
) {
|
||||||
|
// Not really an error due to being a result of user interaction, show nothing
|
||||||
|
} else {
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
modrinthLoginFlowWaitModal.value.hide()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logOut() {
|
async function logOut() {
|
||||||
@@ -418,6 +441,9 @@ function handleAuxClick(e) {
|
|||||||
<Suspense>
|
<Suspense>
|
||||||
<AppSettingsModal ref="settingsModal" />
|
<AppSettingsModal ref="settingsModal" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<Suspense>
|
||||||
|
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
|
||||||
|
</Suspense>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<InstanceCreationModal ref="installationModal" />
|
<InstanceCreationModal ref="installationModal" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -465,12 +491,20 @@ function handleAuxClick(e) {
|
|||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</NavButton>
|
</NavButton>
|
||||||
<div class="flex flex-grow"></div>
|
<div class="flex flex-grow"></div>
|
||||||
<NavButton v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()">
|
<!-- [AR] TODO -->
|
||||||
|
<!-- <NavButton v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()">
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
</NavButton>
|
</NavButton> -->
|
||||||
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
|
<template v-if="updateState">
|
||||||
<SettingsIcon />
|
<NavButton class="neon-icon pulse" v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
|
||||||
</NavButton>
|
<SettingsIcon />
|
||||||
|
</NavButton>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
|
||||||
|
<SettingsIcon />
|
||||||
|
</NavButton>
|
||||||
|
</template>
|
||||||
<ButtonStyled v-if="credentials" type="transparent" circular>
|
<ButtonStyled v-if="credentials" type="transparent" circular>
|
||||||
<OverflowMenu
|
<OverflowMenu
|
||||||
:options="[
|
:options="[
|
||||||
@@ -501,13 +535,13 @@ function handleAuxClick(e) {
|
|||||||
<!-- <ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" /> -->
|
<!-- <ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" /> -->
|
||||||
<div class="flex items-center gap-1 ml-3">
|
<div class="flex items-center gap-1 ml-3">
|
||||||
<button
|
<button
|
||||||
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||||
@click="router.back()"
|
@click="router.back()"
|
||||||
>
|
>
|
||||||
<LeftArrowIcon />
|
<LeftArrowIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||||
@click="router.forward()"
|
@click="router.forward()"
|
||||||
>
|
>
|
||||||
<RightArrowIcon />
|
<RightArrowIcon />
|
||||||
@@ -659,6 +693,9 @@ function handleAuxClick(e) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '../../../packages/assets/styles/neon-icon.scss';
|
||||||
|
@import '../../../packages/assets/styles/neon-text.scss';
|
||||||
|
|
||||||
.window-controls {
|
.window-controls {
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ const filteredResults = computed(() => {
|
|||||||
|
|
||||||
if (sortBy.value === 'Game version') {
|
if (sortBy.value === 'Game version') {
|
||||||
instances.sort((a, b) => {
|
instances.sort((a, b) => {
|
||||||
return a.game_version.localeCompare(b.game_version)
|
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +213,17 @@ const filteredResults = computed(() => {
|
|||||||
instanceMap.set(entry[0], entry[1])
|
instanceMap.set(entry[0], entry[1])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
|
||||||
|
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
|
||||||
|
if (group.value === 'Game version') {
|
||||||
|
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
|
||||||
|
return a[0].localeCompare(b[0], undefined, { numeric: true })
|
||||||
|
})
|
||||||
|
instanceMap.clear()
|
||||||
|
sortedEntries.forEach((entry) => {
|
||||||
|
instanceMap.set(entry[0], entry[1])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return instanceMap
|
return instanceMap
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div v-if="mode !== 'isolated'" ref="button"
|
||||||
v-if="mode !== 'isolated'"
|
|
||||||
ref="button"
|
|
||||||
class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
|
class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
|
||||||
:class="{ expanded: mode === 'expanded' }"
|
:class="{ expanded: mode === 'expanded' }" @click="toggleMenu">
|
||||||
@click="toggleMenu"
|
<Avatar size="36px" :src="selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||||
>
|
" />
|
||||||
<Avatar
|
|
||||||
size="36px"
|
|
||||||
:src="
|
|
||||||
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col w-full">
|
||||||
<span>
|
<span>
|
||||||
<component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" />
|
<component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" />
|
||||||
@@ -32,31 +24,23 @@
|
|||||||
</h4>
|
</h4>
|
||||||
<p>Selected</p>
|
<p>Selected</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.profile.id)">
|
||||||
v-tooltip="'Log out'"
|
|
||||||
icon-only
|
|
||||||
color="raised"
|
|
||||||
@click="logout(selectedAccount.profile.id)"
|
|
||||||
>
|
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="login-section account">
|
<div v-else class="login-section account">
|
||||||
<h4>Not signed in</h4>
|
<h4>Not signed in</h4>
|
||||||
<Button
|
<Button v-tooltip="'Log via Microsoft'" :disabled="microsoftLoginDisabled" icon-only @click="login()">
|
||||||
v-tooltip="'Log in'"
|
<MicrosoftIcon v-if="!microsoftLoginDisabled" />
|
||||||
:disabled="loginDisabled"
|
|
||||||
icon-only
|
|
||||||
color="primary"
|
|
||||||
@click="login()"
|
|
||||||
>
|
|
||||||
<LogInIcon v-if="!loginDisabled" />
|
|
||||||
<SpinnerIcon v-else class="animate-spin" />
|
<SpinnerIcon v-else class="animate-spin" />
|
||||||
<MicrosoftIcon/>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()">
|
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
|
||||||
<PirateIcon />
|
<PirateIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElybyLoginModal()">
|
||||||
|
<ElyByIcon v-if="!elybyLoginDisabled" />
|
||||||
|
<SpinnerIcon v-else class="animate-spin" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="displayAccounts.length > 0" class="account-group">
|
<div v-if="displayAccounts.length > 0" class="account-group">
|
||||||
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
|
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
|
||||||
@@ -73,53 +57,135 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="accounts.length > 0" class="login-section account centered">
|
<div v-if="accounts.length > 0" class="login-section account centered">
|
||||||
<Button v-tooltip="'Log in'" icon-only @click="login()">
|
<Button v-tooltip="'Log via Microsoft'" icon-only @click="login()">
|
||||||
<MicrosoftIcon />
|
<MicrosoftIcon v-if="!microsoftLoginDisabled" />
|
||||||
|
<SpinnerIcon v-else class="animate-spin" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()">
|
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
|
||||||
<PirateIcon />
|
<PirateIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElybyLoginModal()">
|
||||||
|
<ElyByIcon v-if="!elybyLoginDisabled" />
|
||||||
|
<SpinnerIcon v-else class="animate-spin" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</transition>
|
</transition>
|
||||||
<ModalWrapper ref="loginOfflineModal" class="modal" header="Add new offline account">
|
<ModalWrapper ref="addElybyModal" class="modal" header="Authenticate with Ely.by">
|
||||||
<div class="modal-body">
|
<ModalWrapper ref="requestElybyTwoFactorCodeModal" class="modal"
|
||||||
<div class="label">Enter offline username</div>
|
header="Ely.by requested 2FA code for authentication">
|
||||||
<input type="text" v-model="playerName" placeholder="Provide offline player name" />
|
<div class="flex flex-col gap-4 px-6 py-5">
|
||||||
<Button icon-only color="secondary" @click="offlineLoginFinally()">
|
<label class="label">Enter your 2FA code</label>
|
||||||
Continue
|
<input v-model="elybyTwoFactorCode" type="text" placeholder="Your 2FA code here..." class="input" />
|
||||||
</Button>
|
<div class="mt-6 ml-auto">
|
||||||
|
<Button icon-only color="primary" class="continue-button" @click="addElybyProfile()">
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
|
<div class="flex flex-col gap-4 px-6 py-5">
|
||||||
|
<label class="label">Enter your player name or email (preferred)</label>
|
||||||
|
<input v-model="elybyLogin" type="text" placeholder="Your player name or email here..." class="input" />
|
||||||
|
<label class="label">Enter your password</label>
|
||||||
|
<input v-model="elybyPassword" type="password" placeholder="Your password here..." class="input" />
|
||||||
|
<div class="mt-6 ml-auto">
|
||||||
|
<Button icon-only color="primary" class="continue-button" @click="addElybyProfile()">
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
<ModalWrapper ref="loginErrorModal" class="modal" header="Error while proceed">
|
<ModalWrapper ref="addOfflineModal" class="modal" header="Add new offline account">
|
||||||
<div class="modal-body">
|
<div class="flex flex-col gap-4 px-6 py-5">
|
||||||
<div class="label">Error occurred while adding offline account</div>
|
<label class="label">Enter your player name</label>
|
||||||
<Button color="primary" @click="retryOfflineLogin()">
|
<input v-model="offlinePlayerName" type="text" placeholder="Your player name here..." class="input" />
|
||||||
Try again
|
<div class="mt-6 ml-auto">
|
||||||
</Button>
|
<Button icon-only color="primary" class="continue-button" @click="addOfflineProfile()">
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
<ModalWrapper ref="unexpectedErrorModal" class="modal" header="Ошибка">
|
<ModalWrapper
|
||||||
|
ref="authenticationElybyErrorModal"
|
||||||
|
class="modal"
|
||||||
|
header="Error while proceeding authentication event with Ely.by">
|
||||||
|
<div class="flex flex-col gap-4 px-6 py-5">
|
||||||
|
<label class="text-base font-medium text-red-700">
|
||||||
|
An error occurred while logging in.
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="mt-6 ml-auto">
|
||||||
|
<Button color="primary" class="retry-button" @click="retryAddElybyProfile">
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
|
<ModalWrapper ref="inputElybyErrorModal" class="modal" header="Error while proceeding input event with Ely.by">
|
||||||
|
<div class="flex flex-col gap-4 px-6 py-5">
|
||||||
|
<label class="text-base font-medium text-red-700">
|
||||||
|
An error occurred while adding the Ely.by account. Please follow the instructions below.
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<ul class="list-disc list-inside text-sm space-y-1">
|
||||||
|
<li>Check that you have entered the correct player name or email.</li>
|
||||||
|
<li>Check that you have entered the correct password.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-6 ml-auto">
|
||||||
|
<Button color="primary" class="retry-button" @click="retryAddElybyProfile">
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
|
<ModalWrapper ref="inputErrorModal" class="modal" header="Error while proceeding input event with offline account">
|
||||||
|
<div class="flex flex-col gap-4 px-6 py-5">
|
||||||
|
<label class="text-base font-medium text-red-700">
|
||||||
|
An error occurred while adding the offline account. Please follow the instructions below.
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<ul class="list-disc list-inside text-sm space-y-1">
|
||||||
|
<li>Check that you have entered the correct player name.</li>
|
||||||
|
<li>
|
||||||
|
Player name must be at least {{ minOfflinePlayerNameLength }} characters long and no more than
|
||||||
|
{{ maxOfflinePlayerNameLength }} characters.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-6 ml-auto">
|
||||||
|
<Button color="primary" class="retry-button" @click="retryAddOfflineProfile">
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
|
<ModalWrapper ref="exceptionErrorModal" class="modal" header="Unexpected error occurred">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="label">Unexcepted error</div>
|
<label class="label">An unexpected error has occurred. Please try again later.</label>
|
||||||
</div>
|
</div>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
DropdownIcon,
|
DropdownIcon,
|
||||||
PlusIcon,
|
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
LogInIcon,
|
|
||||||
PirateIcon as Offline,
|
PirateIcon as Offline,
|
||||||
MicrosoftIcon as License,
|
MicrosoftIcon as License,
|
||||||
|
ElyByIcon as Elyby,
|
||||||
MicrosoftIcon,
|
MicrosoftIcon,
|
||||||
PirateIcon,
|
PirateIcon,
|
||||||
SpinnerIcon } from '@modrinth/assets'
|
ElyByIcon,
|
||||||
|
SpinnerIcon
|
||||||
|
} from '@modrinth/assets'
|
||||||
import { Avatar, Button, Card } from '@modrinth/ui'
|
import { Avatar, Button, Card } from '@modrinth/ui'
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||||
import {
|
import {
|
||||||
|
elyby_auth_authenticate,
|
||||||
|
elyby_login,
|
||||||
offline_login,
|
offline_login,
|
||||||
users,
|
users,
|
||||||
remove_user,
|
remove_user,
|
||||||
@@ -146,48 +212,180 @@ defineProps({
|
|||||||
const emit = defineEmits(['change'])
|
const emit = defineEmits(['change'])
|
||||||
|
|
||||||
const accounts = ref({})
|
const accounts = ref({})
|
||||||
const loginDisabled = ref(false)
|
const microsoftLoginDisabled = ref(false)
|
||||||
|
const elybyLoginDisabled = ref(false)
|
||||||
const defaultUser = ref()
|
const defaultUser = ref()
|
||||||
const loginOfflineModal = ref(null)
|
|
||||||
const loginErrorModal = ref(null)
|
|
||||||
const unexpectedErrorModal = ref(null)
|
|
||||||
const playerName = ref('')
|
|
||||||
|
|
||||||
async function tryOfflineLogin() { // Patched by AstralRinth
|
// [AR] • Feature
|
||||||
loginOfflineModal.value.show()
|
const clientToken = "astralrinth"
|
||||||
|
const addOfflineModal = ref(null)
|
||||||
|
const addElybyModal = ref(null)
|
||||||
|
const requestElybyTwoFactorCodeModal = ref(null)
|
||||||
|
const authenticationElybyErrorModal = ref(null)
|
||||||
|
const inputElybyErrorModal = ref(null)
|
||||||
|
const inputErrorModal = ref(null)
|
||||||
|
const exceptionErrorModal = ref(null)
|
||||||
|
const offlinePlayerName = ref('')
|
||||||
|
const elybyLogin = ref('')
|
||||||
|
const elybyPassword = ref('')
|
||||||
|
const elybyTwoFactorCode = ref('')
|
||||||
|
const minOfflinePlayerNameLength = 2
|
||||||
|
const maxOfflinePlayerNameLength = 20
|
||||||
|
|
||||||
|
// [AR] • Feature
|
||||||
|
function getAccountType(account) {
|
||||||
|
switch (account.account_type) {
|
||||||
|
case 'microsoft':
|
||||||
|
return License
|
||||||
|
case 'pirate':
|
||||||
|
return Offline
|
||||||
|
case 'elyby':
|
||||||
|
return Elyby
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function offlineLoginFinally() { // Patched by AstralRinth
|
// [AR] • Feature
|
||||||
const name = playerName.value
|
function showOfflineLoginModal() {
|
||||||
if (name.length > 1 && name.length < 20 && name !== '') {
|
addOfflineModal.value?.show()
|
||||||
const loggedIn = await offline_login(name).catch(handleError)
|
}
|
||||||
loginOfflineModal.value.hide()
|
|
||||||
if (loggedIn) {
|
// [AR] • Feature
|
||||||
await setAccount(loggedIn)
|
function showElybyLoginModal() {
|
||||||
|
addElybyModal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// [AR] • Feature
|
||||||
|
function retryAddOfflineProfile() {
|
||||||
|
inputErrorModal.value?.hide()
|
||||||
|
clearOfflineFields()
|
||||||
|
showOfflineLoginModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// [AR] • Feature
|
||||||
|
function retryAddElybyProfile() {
|
||||||
|
authenticationElybyErrorModal.value?.hide()
|
||||||
|
inputElybyErrorModal.value?.hide()
|
||||||
|
clearElybyFields()
|
||||||
|
showElybyLoginModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// [AR] • Feature
|
||||||
|
function clearElybyFields() {
|
||||||
|
elybyLogin.value = ''
|
||||||
|
elybyPassword.value = ''
|
||||||
|
elybyTwoFactorCode.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// [AR] • Feature
|
||||||
|
function clearOfflineFields() {
|
||||||
|
offlinePlayerName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// [AR] • Feature
|
||||||
|
async function addOfflineProfile() {
|
||||||
|
const name = offlinePlayerName.value.trim()
|
||||||
|
const isValidName = name.length >= minOfflinePlayerNameLength && name.length <= maxOfflinePlayerNameLength
|
||||||
|
|
||||||
|
if (!isValidName) {
|
||||||
|
addOfflineModal.value?.hide()
|
||||||
|
inputErrorModal.value?.show()
|
||||||
|
clearOfflineFields()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await offline_login(name)
|
||||||
|
|
||||||
|
addOfflineModal.value?.hide()
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
await setAccount(result)
|
||||||
await refreshValues()
|
await refreshValues()
|
||||||
} else {
|
} else {
|
||||||
unexpectedErrorModal.value.show()
|
exceptionErrorModal.value?.show()
|
||||||
}
|
}
|
||||||
playerName.value = ''
|
} catch (error) {
|
||||||
} else {
|
handleError(error)
|
||||||
playerName.value = ''
|
exceptionErrorModal.value?.show()
|
||||||
loginOfflineModal.value.hide()
|
} finally {
|
||||||
loginErrorModal.value.show()
|
clearOfflineFields()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryOfflineLogin() { // Patched by AstralRinth
|
// [AR] • Feature
|
||||||
loginErrorModal.value.hide()
|
async function addElybyProfile() {
|
||||||
tryOfflineLogin()
|
if (!elybyLogin.value || !elybyPassword.value) {
|
||||||
}
|
addElybyModal.value?.hide()
|
||||||
|
inputElybyErrorModal.value?.show()
|
||||||
|
clearElybyFields()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elybyLoginDisabled.value = true
|
||||||
|
|
||||||
function getAccountType(account) { // Patched by AstralRinth
|
const login = elybyLogin.value.trim()
|
||||||
if (account.access_token != "null" && account.access_token != null && account.access_token != "") {
|
let password = elybyPassword.value.trim()
|
||||||
return License
|
const twoFactorCode = elybyTwoFactorCode.value.trim()
|
||||||
} else {
|
if (password && twoFactorCode) {
|
||||||
return Offline
|
password = `${password}:${twoFactorCode}`
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw_result = await elyby_auth_authenticate(
|
||||||
|
login,
|
||||||
|
password,
|
||||||
|
clientToken
|
||||||
|
)
|
||||||
|
|
||||||
|
const json_data = JSON.parse(raw_result)
|
||||||
|
|
||||||
|
console.log(json_data?.error)
|
||||||
|
console.log(json_data?.errorMessage)
|
||||||
|
|
||||||
|
if (!json_data.accessToken) {
|
||||||
|
if (
|
||||||
|
json_data.error === 'ForbiddenOperationException' &&
|
||||||
|
json_data.errorMessage?.includes('two factor')
|
||||||
|
) {
|
||||||
|
requestElybyTwoFactorCodeModal.value?.show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addElybyModal.value?.hide()
|
||||||
|
requestElybyTwoFactorCodeModal.value?.hide()
|
||||||
|
authenticationElybyErrorModal.value?.show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = json_data.accessToken
|
||||||
|
const selectedProfileId = convertRawStringToUUIDv4(json_data.selectedProfile.id)
|
||||||
|
const selectedProfileName = json_data.selectedProfile.name
|
||||||
|
|
||||||
|
const result = await elyby_login(selectedProfileId, selectedProfileName, accessToken)
|
||||||
|
|
||||||
|
addElybyModal.value?.hide()
|
||||||
|
requestElybyTwoFactorCodeModal.value?.hide()
|
||||||
|
|
||||||
|
clearElybyFields()
|
||||||
|
|
||||||
|
await setAccount(result)
|
||||||
|
await refreshValues()
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err)
|
||||||
|
exceptionErrorModal.value?.show()
|
||||||
|
} finally {
|
||||||
|
elybyLoginDisabled.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [AR] • Feature
|
||||||
|
function convertRawStringToUUIDv4(rawId) {
|
||||||
|
if (rawId.length !== 32) {
|
||||||
|
console.warn('Invalid UUID string:', rawId)
|
||||||
|
return rawId
|
||||||
|
}
|
||||||
|
return `${rawId.slice(0, 8)}-${rawId.slice(8, 12)}-${rawId.slice(12, 16)}-${rawId.slice(16, 20)}-${rawId.slice(20)}`
|
||||||
|
}
|
||||||
|
|
||||||
const equippedSkin = ref(null)
|
const equippedSkin = ref(null)
|
||||||
const headUrlCache = ref(new Map())
|
const headUrlCache = ref(new Map())
|
||||||
|
|
||||||
@@ -213,13 +411,13 @@ async function refreshValues() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setLoginDisabled(value) {
|
function setLoginDisabled(value) {
|
||||||
loginDisabled.value = value
|
microsoftLoginDisabled.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
refreshValues,
|
refreshValues,
|
||||||
setLoginDisabled,
|
setLoginDisabled,
|
||||||
loginDisabled,
|
loginDisabled: microsoftLoginDisabled,
|
||||||
})
|
})
|
||||||
await refreshValues()
|
await refreshValues()
|
||||||
|
|
||||||
@@ -265,7 +463,7 @@ async function setAccount(account) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
loginDisabled.value = true
|
microsoftLoginDisabled.value = true
|
||||||
const loggedIn = await login_flow().catch(handleSevereError)
|
const loggedIn = await login_flow().catch(handleSevereError)
|
||||||
|
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
@@ -274,7 +472,7 @@ async function login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
trackEvent('AccountLogIn')
|
trackEvent('AccountLogIn')
|
||||||
loginDisabled.value = false
|
microsoftLoginDisabled.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = async (id) => {
|
const logout = async (id) => {
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
<template>
|
||||||
|
<ModalWrapper ref="modal" :header="'Import from CurseForge Profile Code'">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="input-row">
|
||||||
|
<p class="input-label">Profile Code</p>
|
||||||
|
<div class="iconified-input">
|
||||||
|
<SearchIcon aria-hidden="true" class="text-lg" />
|
||||||
|
<input
|
||||||
|
ref="codeInput"
|
||||||
|
v-model="profileCode"
|
||||||
|
autocomplete="off"
|
||||||
|
class="h-12 card-shadow"
|
||||||
|
spellcheck="false"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter CurseForge profile code"
|
||||||
|
maxlength="20"
|
||||||
|
@keyup.enter="importProfile"
|
||||||
|
/>
|
||||||
|
<Button v-if="profileCode" class="r-btn" @click="() => (profileCode = '')">
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="metadata && !importing" class="profile-info">
|
||||||
|
<h3>Profile Information</h3>
|
||||||
|
<p><strong>Name:</strong> {{ metadata.name }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-message">
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="importing && importProgress.visible" class="progress-section">
|
||||||
|
<div class="progress-info">
|
||||||
|
<span class="progress-text">{{ importProgress.message }}</span>
|
||||||
|
<span class="progress-percentage">{{ Math.floor(importProgress.percentage) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<div
|
||||||
|
class="progress-bar"
|
||||||
|
:style="{ width: `${importProgress.percentage}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<Button @click="hide" :disabled="importing">
|
||||||
|
<XIcon />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="!metadata"
|
||||||
|
@click="fetchMetadata"
|
||||||
|
:disabled="!profileCode.trim() || fetching"
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
<SearchIcon v-if="!fetching" />
|
||||||
|
{{ fetching ? 'Checking...' : 'Check Profile' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="metadata"
|
||||||
|
@click="importProfile"
|
||||||
|
:disabled="importing"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
<DownloadIcon v-if="!importing" />
|
||||||
|
{{ importing ? 'Importing...' : 'Import Profile' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
|
import { Button } from '@modrinth/ui'
|
||||||
|
import {
|
||||||
|
XIcon,
|
||||||
|
SearchIcon,
|
||||||
|
DownloadIcon
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
fetch_curseforge_profile_metadata,
|
||||||
|
import_curseforge_profile
|
||||||
|
} from '@/helpers/import.js'
|
||||||
|
import { handleError } from '@/store/notifications.js'
|
||||||
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
|
import { loading_listener } from '@/helpers/events.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
closeParent: {
|
||||||
|
type: Function,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const modal = ref(null)
|
||||||
|
const codeInput = ref(null)
|
||||||
|
const profileCode = ref('')
|
||||||
|
const metadata = ref(null)
|
||||||
|
const fetching = ref(false)
|
||||||
|
const importing = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const importProgress = ref({
|
||||||
|
visible: false,
|
||||||
|
percentage: 0,
|
||||||
|
message: 'Starting import...',
|
||||||
|
totalMods: 0,
|
||||||
|
downloadedMods: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
let unlistenLoading = null
|
||||||
|
let activeLoadingBarId = null
|
||||||
|
let progressFallbackTimer = null
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
show: () => {
|
||||||
|
profileCode.value = ''
|
||||||
|
metadata.value = null
|
||||||
|
fetching.value = false
|
||||||
|
importing.value = false
|
||||||
|
error.value = ''
|
||||||
|
importProgress.value = {
|
||||||
|
visible: false,
|
||||||
|
percentage: 0,
|
||||||
|
message: 'Starting import...',
|
||||||
|
totalMods: 0,
|
||||||
|
downloadedMods: 0
|
||||||
|
}
|
||||||
|
modal.value?.show()
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
codeInput.value?.focus()
|
||||||
|
}, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
trackEvent('CurseForgeProfileImportStart', { source: 'ImportModal' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
modal.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMetadata = async () => {
|
||||||
|
if (!profileCode.value.trim()) return
|
||||||
|
|
||||||
|
fetching.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetch_curseforge_profile_metadata(profileCode.value.trim())
|
||||||
|
metadata.value = result
|
||||||
|
trackEvent('CurseForgeProfileMetadataFetched', {
|
||||||
|
profileCode: profileCode.value.trim()
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch CurseForge profile metadata:', err)
|
||||||
|
error.value = 'Failed to fetch profile information. Please check the code and try again.'
|
||||||
|
handleError(err)
|
||||||
|
} finally {
|
||||||
|
fetching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const importProfile = async () => {
|
||||||
|
if (!profileCode.value.trim()) return
|
||||||
|
|
||||||
|
importing.value = true
|
||||||
|
error.value = ''
|
||||||
|
activeLoadingBarId = null // Reset for new import session
|
||||||
|
importProgress.value = {
|
||||||
|
visible: true,
|
||||||
|
percentage: 0,
|
||||||
|
message: 'Starting import...',
|
||||||
|
totalMods: 0,
|
||||||
|
downloadedMods: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback progress timer in case loading events don't work
|
||||||
|
progressFallbackTimer = setInterval(() => {
|
||||||
|
if (importing.value && importProgress.value.percentage < 90) {
|
||||||
|
// Slowly increment progress as a fallback
|
||||||
|
importProgress.value.percentage = Math.min(90, importProgress.value.percentage + 1)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result, profilePath } = await import_curseforge_profile(profileCode.value.trim())
|
||||||
|
|
||||||
|
trackEvent('CurseForgeProfileImported', {
|
||||||
|
profileCode: profileCode.value.trim()
|
||||||
|
})
|
||||||
|
|
||||||
|
hide()
|
||||||
|
|
||||||
|
// Close the parent modal if provided
|
||||||
|
if (props.closeParent) {
|
||||||
|
props.closeParent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the imported profile
|
||||||
|
await router.push(`/instance/${encodeURIComponent(profilePath)}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to import CurseForge profile:', err)
|
||||||
|
error.value = 'Failed to import profile. Please try again.'
|
||||||
|
handleError(err)
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
importProgress.value.visible = false
|
||||||
|
if (progressFallbackTimer) {
|
||||||
|
clearInterval(progressFallbackTimer)
|
||||||
|
progressFallbackTimer = null
|
||||||
|
}
|
||||||
|
activeLoadingBarId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Listen for loading events to update progress
|
||||||
|
unlistenLoading = await loading_listener((event) => {
|
||||||
|
console.log('Loading event received:', event) // Debug log
|
||||||
|
|
||||||
|
// Handle all loading events that could be related to CurseForge profile import
|
||||||
|
const isCurseForgeEvent = event.event?.type === 'curseforge_profile_download'
|
||||||
|
const hasProfileName = event.event?.profile_name && importing.value
|
||||||
|
|
||||||
|
if ((isCurseForgeEvent || hasProfileName) && importing.value) {
|
||||||
|
// Store the loading bar ID for this import session
|
||||||
|
if (!activeLoadingBarId) {
|
||||||
|
activeLoadingBarId = event.loader_uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process events for our current import session
|
||||||
|
if (event.loader_uuid === activeLoadingBarId) {
|
||||||
|
if (event.fraction !== null && event.fraction !== undefined) {
|
||||||
|
const baseProgress = (event.fraction || 0) * 100
|
||||||
|
|
||||||
|
// Calculate custom progress based on the message
|
||||||
|
let finalProgress = baseProgress
|
||||||
|
const message = event.message || 'Importing profile...'
|
||||||
|
|
||||||
|
// Custom progress calculation for different stages
|
||||||
|
if (message.includes('Fetching') || message.includes('metadata')) {
|
||||||
|
finalProgress = Math.min(10, baseProgress)
|
||||||
|
} else if (message.includes('Downloading profile ZIP') || message.includes('profile ZIP')) {
|
||||||
|
finalProgress = Math.min(15, 10 + (baseProgress - 10) * 0.5)
|
||||||
|
} else if (message.includes('Extracting') || message.includes('ZIP')) {
|
||||||
|
finalProgress = Math.min(20, 15 + (baseProgress - 15) * 0.5)
|
||||||
|
} else if (message.includes('Configuring') || message.includes('profile')) {
|
||||||
|
finalProgress = Math.min(30, 20 + (baseProgress - 20) * 0.5)
|
||||||
|
} else if (message.includes('Copying') || message.includes('files')) {
|
||||||
|
finalProgress = Math.min(40, 30 + (baseProgress - 30) * 0.5)
|
||||||
|
} else if (message.includes('Downloaded mod') && message.includes(' of ')) {
|
||||||
|
// Parse "Downloaded mod X of Y" message
|
||||||
|
const match = message.match(/Downloaded mod (\d+) of (\d+)/)
|
||||||
|
if (match) {
|
||||||
|
const current = parseInt(match[1])
|
||||||
|
const total = parseInt(match[2])
|
||||||
|
// Mods take 40% of progress (from 40% to 80%)
|
||||||
|
const modProgress = (current / total) * 40
|
||||||
|
finalProgress = 40 + modProgress
|
||||||
|
} else {
|
||||||
|
finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.5)
|
||||||
|
}
|
||||||
|
} else if (message.includes('Downloading mod') || message.includes('mods')) {
|
||||||
|
// General mod downloading stage (40% to 80%)
|
||||||
|
finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.4)
|
||||||
|
} else if (message.includes('Installing Minecraft') || message.includes('Minecraft')) {
|
||||||
|
finalProgress = Math.min(95, 80 + (baseProgress - 80) * 0.75)
|
||||||
|
} else if (message.includes('Finalizing') || message.includes('completed')) {
|
||||||
|
finalProgress = Math.min(100, 95 + (baseProgress - 95))
|
||||||
|
} else {
|
||||||
|
// Default: use the base progress but ensure minimum progression
|
||||||
|
finalProgress = Math.max(importProgress.value.percentage, baseProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
importProgress.value.percentage = Math.min(100, Math.max(0, finalProgress))
|
||||||
|
importProgress.value.message = message
|
||||||
|
} else {
|
||||||
|
// Loading complete
|
||||||
|
importProgress.value.percentage = 100
|
||||||
|
importProgress.value.message = 'Import completed!'
|
||||||
|
activeLoadingBarId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unlistenLoading) {
|
||||||
|
unlistenLoading()
|
||||||
|
}
|
||||||
|
if (progressFallbackTimer) {
|
||||||
|
clearInterval(progressFallbackTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.modal-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-contrast);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-button);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--color-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
color: var(--color-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: var(--color-red);
|
||||||
|
border: 1px solid var(--color-red);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.75rem;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-contrast);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
color: var(--color-base);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-percentage {
|
||||||
|
color: var(--color-contrast);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--color-button);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-brand);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -18,11 +18,16 @@ import { cancel_directory_change } from '@/helpers/settings.ts'
|
|||||||
import { install } from '@/helpers/profile.js'
|
import { install } from '@/helpers/profile.js'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
|
import { applyMigrationFix } from '@/helpers/utils.js'
|
||||||
|
import { restartApp } from '@/helpers/utils.js'
|
||||||
|
|
||||||
const errorModal = ref()
|
const errorModal = ref()
|
||||||
const error = ref()
|
const error = ref()
|
||||||
const closable = ref(true)
|
const closable = ref(true)
|
||||||
const errorCollapsed = ref(false)
|
const errorCollapsed = ref(false)
|
||||||
|
const language = ref('en')
|
||||||
|
const migrationFixSuccess = ref(null) // null | true | false
|
||||||
|
const migrationFixCallbackModel = ref()
|
||||||
|
|
||||||
const title = ref('An error occurred')
|
const title = ref('An error occurred')
|
||||||
const errorType = ref('unknown')
|
const errorType = ref('unknown')
|
||||||
@@ -148,6 +153,30 @@ async function copyToClipboard(text) {
|
|||||||
copied.value = false
|
copied.value = false
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleLanguage() {
|
||||||
|
language.value = language.value === 'en' ? 'ru' : 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onApplyMigrationFix(eol) {
|
||||||
|
console.log(`[AR] • Attempting to apply migration ${eol.toUpperCase()} fix`)
|
||||||
|
try {
|
||||||
|
const result = await applyMigrationFix(eol)
|
||||||
|
migrationFixSuccess.value = result === true
|
||||||
|
console.log(`[AR] • Successfully applied migration ${eol.toUpperCase()} fix`, result)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[AR] • Failed to apply migration fix:`, err)
|
||||||
|
migrationFixSuccess.value = false
|
||||||
|
} finally {
|
||||||
|
migrationFixCallbackModel.value?.show?.()
|
||||||
|
if (migrationFixSuccess.value === true) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
await restartApp()
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -298,10 +327,20 @@ async function copyToClipboard(text) {
|
|||||||
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
|
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
|
||||||
<template v-else> <CopyIcon /> Copy debug info </template>
|
<template v-else> <CopyIcon /> Copy debug info </template>
|
||||||
</button>
|
</button>
|
||||||
|
<ButtonStyled class="neon-button neon">
|
||||||
|
<a href="https://me.astralium.su/get/ar/help" target="_blank" rel="noopener noreferrer">
|
||||||
|
Get AstralRinth support
|
||||||
|
</a>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled class="neon-button neon" >
|
||||||
|
<a href="https://me.astralium.su/get/ar" target="_blank" rel="noopener noreferrer">
|
||||||
|
Checkout latest releases
|
||||||
|
</a>
|
||||||
|
</ButtonStyled>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="hasDebugInfo">
|
<template v-if="hasDebugInfo">
|
||||||
<div class="bg-button-bg rounded-xl mt-2 overflow-clip">
|
<div class="bg-button-bg rounded-xl mt-2 overflow-hidden">
|
||||||
<button
|
<button
|
||||||
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
|
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
|
||||||
@click="errorCollapsed = !errorCollapsed"
|
@click="errorCollapsed = !errorCollapsed"
|
||||||
@@ -313,12 +352,123 @@ async function copyToClipboard(text) {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<Collapsible :collapsed="errorCollapsed">
|
<Collapsible :collapsed="errorCollapsed">
|
||||||
<pre class="m-0 px-4 py-3 bg-bg rounded-none">{{ debugInfo }}</pre>
|
<pre
|
||||||
|
class="m-0 px-4 py-3 bg-bg rounded-none whitespace-pre-wrap break-words overflow-x-auto max-w-full"
|
||||||
|
>{{ debugInfo }}</pre>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="errorType === 'state_init'">
|
||||||
|
<div class="notice">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 v-if="language === 'en'" class="notice__title">⚠️ Migration Issue • Important Notice ⚠️</h3>
|
||||||
|
<h3 v-if="language === 'ru'" class="notice__title">⚠️ Проблема миграции • Важное уведомление ⚠️</h3>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="toggleLanguage">
|
||||||
|
{{ language === 'en' ? '📖 Русский' : '📖 English' }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
<p v-if="language === 'en'" class="notice__text">
|
||||||
|
We're experiencing an issue with our database migration system due to differences in how different operating systems handle line endings. This might cause problems with our app's functionality.
|
||||||
|
</p>
|
||||||
|
<p v-if="language === 'en'" class="notice__text">
|
||||||
|
<strong>What's happening?</strong> When we build our app, we use a system that checks the integrity of our database migrations. However, this system can get confused when it encounters different line endings (like CRLF vs LF) used by different operating systems. This can lead to errors and make our app unusable.
|
||||||
|
</p>
|
||||||
|
<p v-if="language === 'en'" class="notice__text">
|
||||||
|
<strong>Why is this happening?</strong> This issue is caused by a combination of factors, including different operating systems handling line endings differently, Git's line ending conversion settings, and our app's build process.
|
||||||
|
</p>
|
||||||
|
<p v-if="language === 'en'" class="notice__text">
|
||||||
|
<strong>What are we doing about it?</strong> We're working to resolve this issue and ensure that our app works smoothly for all users. In the meantime, we apologize for any inconvenience this might cause and appreciate your patience and understanding.
|
||||||
|
</p>
|
||||||
|
<p v-if="language === 'ru'" class="notice__text">
|
||||||
|
Мы сталкиваемся с проблемой в нашей системе миграции базы данных из-за различий в том, как разные операционные системы обрабатывают окончания строк. Это может вызвать проблемы с функциональностью нашего приложения.
|
||||||
|
</p>
|
||||||
|
<p v-if="language === 'ru'" class="notice__text">
|
||||||
|
<strong>Что происходит?</strong> Когда мы строим наше приложение, мы используем систему, которая проверяет целостность наших миграций базы данных. Однако эта система может сбиваться, когда сталкивается с различными окончаниями строк (например, CRLF против LF), используемыми разными операционными системами. Это может привести к ошибкам и сделать наше приложение неработоспособным.
|
||||||
|
</p>
|
||||||
|
<p v-if="language === 'ru'" class="notice__text">
|
||||||
|
<strong>Почему это происходит?</strong> Эта проблема вызвана сочетанием факторов, включая различную обработку окончаний строк разными операционными системами, настройки преобразования окончаний строк в Git и процесс сборки нашего приложения.
|
||||||
|
</p>
|
||||||
|
<p v-if="language === 'ru'" class="notice__text">
|
||||||
|
<strong>Что мы с этим делаем?</strong> Мы работаем над решением этой проблемы и обеспечением бесперебойной работы нашего приложения для всех пользователей. В это время мы извиняемся за возможные неудобства и благодарим вас за терпение и понимание.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-bold text-contrast">
|
||||||
|
<template v-if="language === 'en'">Possible fix in real time:</template>
|
||||||
|
<template v-if="language === 'ru'">Возможное исправление в реальном времени:</template>
|
||||||
|
</h2>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<ol class="flex flex-col gap-3">
|
||||||
|
<li>
|
||||||
|
<ButtonStyled class="neon-button neon">
|
||||||
|
<button
|
||||||
|
:title="language === 'en'
|
||||||
|
? 'Convert all line endings in migration files to LF (Unix-style: \\n)'
|
||||||
|
: 'Преобразовать все окончания строк в файлах миграций в LF (Unix-стиль: \\n)'"
|
||||||
|
aria-label="LF"
|
||||||
|
@click="onApplyMigrationFix('lf')"
|
||||||
|
>
|
||||||
|
{{ language === 'en' ? 'Apply LF Migration Fix' : 'Применить исправление миграции LF' }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<ButtonStyled class="neon-button neon">
|
||||||
|
<button
|
||||||
|
:title="language === 'en'
|
||||||
|
? 'Convert all line endings in migration files to CRLF (Windows-style: \\r\\n)'
|
||||||
|
: 'Преобразовать все окончания строк в файлах миграций в CRLF (Windows-стиль: \\r\\n)'"
|
||||||
|
aria-label="CRLF"
|
||||||
|
@click="onApplyMigrationFix('crlf')"
|
||||||
|
>
|
||||||
|
{{ language === 'en' ? 'Apply CRLF Migration Fix' : 'Применить исправление миграции CRLF' }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
|
<ModalWrapper
|
||||||
|
ref="migrationFixCallbackModel"
|
||||||
|
:header="language === 'en'
|
||||||
|
? '💡 Migration fix report'
|
||||||
|
: '💡 Отчет об исправлении миграции'"
|
||||||
|
:closable="closable">
|
||||||
|
<div class="modal-body">
|
||||||
|
<h2 class="text-lg font-bold text-contrast space-y-2">
|
||||||
|
<template v-if="migrationFixSuccess === true">
|
||||||
|
<p class="flex items-center gap-2 neon-text">
|
||||||
|
✅
|
||||||
|
{{ language === 'en'
|
||||||
|
? 'The migration fix has been applied successfully. Please restart the launcher and try to log in to the game :)'
|
||||||
|
: 'Исправление миграции успешно применено. Пожалуйста, перезапустите лаунчер и попробуйте снова авторизоваться в игре :)' }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm neon-text">
|
||||||
|
{{ language === 'en'
|
||||||
|
? 'If the problem persists, please try the other fix.'
|
||||||
|
: 'Если проблема сохраняется, пожалуйста, попробуйте другой способ.' }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="migrationFixSuccess === false">
|
||||||
|
<p class="flex items-center gap-2 neon-text">
|
||||||
|
❌
|
||||||
|
{{ language === 'en'
|
||||||
|
? 'The migration fix failed or had no effect.'
|
||||||
|
: 'Исправление миграции не было успешно применено или не имело эффекта.' }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm neon-text">
|
||||||
|
{{ language === 'en'
|
||||||
|
? 'If the problem persists, please try the other fix.'
|
||||||
|
: 'Если проблема сохраняется, пожалуйста, попробуйте другой способ.' }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -333,6 +483,9 @@ async function copyToClipboard(text) {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
@import '../../../../../packages/assets/styles/neon-button.scss';
|
||||||
|
@import '../../../../../packages/assets/styles/neon-text.scss';
|
||||||
|
|
||||||
.cta-button {
|
.cta-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -163,6 +163,14 @@
|
|||||||
<div v-else class="table-content empty">No profiles found</div>
|
<div v-else class="table-content empty">No profiles found</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
|
<Button
|
||||||
|
v-if="selectedProfileType.name === 'Curseforge'"
|
||||||
|
@click="showCurseForgeProfileModal"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
<CodeIcon />
|
||||||
|
Import from Profile Code
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
:disabled="
|
:disabled="
|
||||||
loading ||
|
loading ||
|
||||||
@@ -194,10 +202,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
|
<CurseForgeProfileImportModal ref="curseforgeProfileModal" :close-parent="hide" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
|
import CurseForgeProfileImportModal from '@/components/ui/CurseForgeProfileImportModal.vue'
|
||||||
import {
|
import {
|
||||||
CodeIcon,
|
CodeIcon,
|
||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
@@ -283,6 +293,11 @@ const hide = () => {
|
|||||||
unlistener.value = null
|
unlistener.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showCurseForgeProfileModal = () => {
|
||||||
|
curseforgeProfileModal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (unlistener.value) {
|
if (unlistener.value) {
|
||||||
unlistener.value()
|
unlistener.value()
|
||||||
@@ -305,12 +320,16 @@ const [
|
|||||||
get_game_versions().then(shallowRef).catch(handleError),
|
get_game_versions().then(shallowRef).catch(handleError),
|
||||||
get_loaders()
|
get_loaders()
|
||||||
.then((value) =>
|
.then((value) =>
|
||||||
value
|
ref(
|
||||||
.filter((item) => item.supported_project_types.includes('modpack'))
|
value
|
||||||
.map((item) => item.name.toLowerCase()),
|
.filter((item) => item.supported_project_types.includes('modpack'))
|
||||||
|
.map((item) => item.name.toLowerCase()),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.then(ref)
|
.catch((err) => {
|
||||||
.catch(handleError),
|
handleError(err)
|
||||||
|
return ref([])
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
loaders.value.unshift('vanilla')
|
loaders.value.unshift('vanilla')
|
||||||
|
|
||||||
@@ -334,6 +353,7 @@ const game_versions = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const modal = ref(null)
|
const modal = ref(null)
|
||||||
|
const curseforgeProfileModal = ref(null)
|
||||||
|
|
||||||
const check_valid = computed(() => {
|
const check_valid = computed(() => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ async function testJava() {
|
|||||||
testingJava.value = true
|
testingJava.value = true
|
||||||
testingJavaSuccess.value = await test_jre(
|
testingJavaSuccess.value = await test_jre(
|
||||||
props.modelValue ? props.modelValue.path : '',
|
props.modelValue ? props.modelValue.path : '',
|
||||||
1,
|
|
||||||
props.version,
|
props.version,
|
||||||
)
|
)
|
||||||
testingJava.value = false
|
testingJava.value = false
|
||||||
|
|||||||
@@ -36,44 +36,6 @@
|
|||||||
<span class="circle stopped" />
|
<span class="circle stopped" />
|
||||||
<span class="running-text"> No instances running </span>
|
<span class="running-text"> No instances running </span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="updateState">
|
|
||||||
<a>
|
|
||||||
<Button class="download" :disabled="installState" @click="confirmUpdating(), getRemote(false, false)">
|
|
||||||
<DownloadIcon />
|
|
||||||
{{
|
|
||||||
installState
|
|
||||||
? "Downloading new update..."
|
|
||||||
: "Download new update"
|
|
||||||
}}
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<ModalWrapper ref="confirmUpdate" :has-to-type="false" header="Request to update the AstralRinth launcher">
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="markdown-body">
|
|
||||||
<p>The new version of the AstralRinth launcher is available.</p>
|
|
||||||
<p>Your version is outdated. We recommend that you update to the latest version.</p>
|
|
||||||
<p><strong>⚠️ Warning ⚠️</strong></p>
|
|
||||||
<p>
|
|
||||||
Before updating, make sure that you have saved all running instances and made a backup copy of the instances
|
|
||||||
that are valuable to you. Remember that the authors of the product are not responsible for the breakdown of
|
|
||||||
your files, so you should always make copies of them and keep them in a safe place.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span>Source • Git Astralium</span>
|
|
||||||
<span>Version on remote server • <p id="releaseData" class="cosmic inline-fix"></p></span>
|
|
||||||
<span>Version on local device •
|
|
||||||
<p class="cosmic inline-fix">v{{ version }}</p>
|
|
||||||
</span>
|
|
||||||
<div class="button-group push-right">
|
|
||||||
<Button class="download-modal" @click="confirmUpdate.hide()">
|
|
||||||
Decline</Button>
|
|
||||||
<Button class="download-modal" @click="approveUpdate()">
|
|
||||||
Accept
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ModalWrapper>
|
|
||||||
</div>
|
</div>
|
||||||
<transition name="download">
|
<transition name="download">
|
||||||
<Card v-if="showCard === true && currentLoadingBars.length > 0" ref="card" class="info-card">
|
<Card v-if="showCard === true && currentLoadingBars.length > 0" ref="card" class="info-card">
|
||||||
@@ -122,25 +84,6 @@ import ProgressBar from '@/components/ui/ProgressBar.vue'
|
|||||||
import { handleError } from '@/store/notifications.js'
|
import { handleError } from '@/store/notifications.js'
|
||||||
import { get_many } from '@/helpers/profile.js'
|
import { get_many } from '@/helpers/profile.js'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
import { getVersion } from '@tauri-apps/api/app'
|
|
||||||
|
|
||||||
const version = await getVersion()
|
|
||||||
|
|
||||||
import { installState, getRemote, updateState } from '@/helpers/update.js'
|
|
||||||
import ModalWrapper from './modal/ModalWrapper.vue'
|
|
||||||
|
|
||||||
const confirmUpdate = ref(null)
|
|
||||||
|
|
||||||
const confirmUpdating = async () => {
|
|
||||||
confirmUpdate.value.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
const approveUpdate = async () => {
|
|
||||||
confirmUpdate.value.hide()
|
|
||||||
await getRemote(true, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
await getRemote(true, false)
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const card = ref(null)
|
const card = ref(null)
|
||||||
@@ -298,101 +241,6 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.inline-fix {
|
|
||||||
display: inline-flex;
|
|
||||||
margin-top: -2rem;
|
|
||||||
margin-bottom: -2rem;
|
|
||||||
//margin-left: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cosmic {
|
|
||||||
color: #3e8cde;
|
|
||||||
text-decoration: none;
|
|
||||||
text-shadow:
|
|
||||||
0 0 4px rgba(79, 173, 255, 0.5),
|
|
||||||
0 0 8px rgba(14, 98, 204, 0.5),
|
|
||||||
0 0 12px rgba(122, 31, 199, 0.5);
|
|
||||||
transition: color 0.35s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body {
|
|
||||||
:deep(table) {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(hr),
|
|
||||||
:deep(h1),
|
|
||||||
:deep(h2) {
|
|
||||||
max-width: max(60rem, 90%);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(ul),
|
|
||||||
:deep(ol) {
|
|
||||||
margin-left: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: var(--gap-lg);
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
.button-group {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
strong {
|
|
||||||
color: var(--color-contrast);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.download {
|
|
||||||
color: #3e8cde;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--color-button-bg);
|
|
||||||
// padding: var(--gap-sm) var(--gap-lg);
|
|
||||||
background-color: rgba(0, 0, 0, 0);
|
|
||||||
text-decoration: none;
|
|
||||||
text-shadow:
|
|
||||||
0 0 4px rgba(79, 173, 255, 0.5),
|
|
||||||
0 0 8px rgba(14, 98, 204, 0.5),
|
|
||||||
0 0 12px rgba(122, 31, 199, 0.5);
|
|
||||||
transition: color 0.35s ease;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download:hover,
|
|
||||||
.download:focus,
|
|
||||||
.download:active {
|
|
||||||
color: #10fae5;
|
|
||||||
text-shadow: #26065e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-modal {
|
|
||||||
color: #3e8cde;
|
|
||||||
padding: var(--gap-sm) var(--gap-lg);
|
|
||||||
text-decoration: none;
|
|
||||||
text-shadow:
|
|
||||||
0 0 4px rgba(79, 173, 255, 0.5),
|
|
||||||
0 0 8px rgba(14, 98, 204, 0.5),
|
|
||||||
0 0 12px rgba(122, 31, 199, 0.5);
|
|
||||||
transition: color 0.35s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-modal:hover,
|
|
||||||
.download-modal:focus,
|
|
||||||
.download-modal:active {
|
|
||||||
color: #10fae5;
|
|
||||||
text-shadow: #26065e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-groups {
|
.action-groups {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
type Version,
|
type Version,
|
||||||
} from '@modrinth/utils'
|
} from '@modrinth/utils'
|
||||||
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
||||||
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
import { get_project, get_version_many } from '@/helpers/cache'
|
import { get_project, get_version_many } from '@/helpers/cache'
|
||||||
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
|
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@@ -35,6 +36,11 @@ import type {
|
|||||||
Manifest,
|
Manifest,
|
||||||
} from '../../../helpers/types'
|
} from '../../../helpers/types'
|
||||||
|
|
||||||
|
import { initAuthlibPatching } from '@/helpers/utils.js'
|
||||||
|
const authLibPatchingModal = ref(null)
|
||||||
|
const isAuthLibPatchedSuccess = ref(false)
|
||||||
|
const isAuthLibPatching = ref(false)
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
const repairConfirmModal = ref()
|
const repairConfirmModal = ref()
|
||||||
@@ -447,9 +453,43 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'reinstall',
|
defaultMessage: 'reinstall',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function handleInitAuthLibPatching(ismojang: boolean) {
|
||||||
|
isAuthLibPatching.value = true
|
||||||
|
let state = false
|
||||||
|
let instance_path = props.instance.loader_version != null ? props.instance.game_version + "-" + props.instance.loader_version : props.instance.game_version
|
||||||
|
try {
|
||||||
|
state = await initAuthlibPatching(instance_path, ismojang)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
isAuthLibPatching.value = false
|
||||||
|
isAuthLibPatchedSuccess.value = state
|
||||||
|
authLibPatchingModal.value.show()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<ModalWrapper
|
||||||
|
ref="authLibPatchingModal"
|
||||||
|
:header="'AuthLib installation report'"
|
||||||
|
:closable="true"
|
||||||
|
@close="authLibPatchingModal.hide()"
|
||||||
|
>
|
||||||
|
<div class="modal-body">
|
||||||
|
<h2 class="text-lg font-bold text-contrast space-y-2">
|
||||||
|
<p class="flex items-center gap-2 neon-text">
|
||||||
|
<span v-if="isAuthLibPatchedSuccess" class="neon-text">
|
||||||
|
AuthLib installation completed successfully! Now you can log in and play!
|
||||||
|
</span>
|
||||||
|
<span v-else class="neon-text">
|
||||||
|
Failed to install AuthLib. It's possible that no compatible AuthLib version was found for the selected game and/or mod loader version.
|
||||||
|
There may also be a problem with accessing resources behind CloudFlare.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
<ConfirmModalWrapper
|
<ConfirmModalWrapper
|
||||||
ref="repairConfirmModal"
|
ref="repairConfirmModal"
|
||||||
:title="formatMessage(messages.repairConfirmTitle)"
|
:title="formatMessage(messages.repairConfirmTitle)"
|
||||||
@@ -720,6 +760,24 @@ const messages = defineMessages({
|
|||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
|
<h2 class="m-0 mt-4 text-lg font-extrabold text-contrast block">
|
||||||
|
<div v-if="isAuthLibPatching" class="w-6 h-6 cursor-pointer hover:brightness-75 neon-icon pulse">
|
||||||
|
<SpinnerIcon class="size-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
Auth system (Skins) <span class="text-sm font-bold px-2 bg-brand-highlight text-brand rounded-full">Beta</span>
|
||||||
|
</h2>
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<ButtonStyled class="neon-button neon">
|
||||||
|
<button :disabled="isAuthLibPatching" @click="handleInitAuthLibPatching(true)">
|
||||||
|
Install Microsoft
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled class="neon-button neon">
|
||||||
|
<button :disabled="isAuthLibPatching" @click="handleInitAuthLibPatching(false) ">
|
||||||
|
Install Ely.By
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<template v-if="instance.linked_data && instance.linked_data.locked">
|
<template v-if="instance.linked_data && instance.linked_data.locked">
|
||||||
@@ -787,3 +845,9 @@ const messages = defineMessages({
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '../../../../../../packages/assets/styles/neon-button.scss';
|
||||||
|
@import '../../../../../../packages/assets/styles/neon-text.scss';
|
||||||
|
@import '../../../../../../packages/assets/styles/neon-icon.scss';
|
||||||
|
</style>
|
||||||
@@ -6,9 +6,9 @@ import { edit, get_optimal_jre_key } from '@/helpers/profile'
|
|||||||
import { handleError } from '@/store/notifications'
|
import { handleError } from '@/store/notifications'
|
||||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||||
import { get_max_memory } from '@/helpers/jre'
|
|
||||||
import { get } from '@/helpers/settings.ts'
|
import { get } from '@/helpers/settings.ts'
|
||||||
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
|
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
|
||||||
|
import useMemorySlider from '@/composables/useMemorySlider'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ const envVars = ref(
|
|||||||
|
|
||||||
const overrideMemorySettings = ref(!!props.instance.memory)
|
const overrideMemorySettings = ref(!!props.instance.memory)
|
||||||
const memory = ref(props.instance.memory ?? globalSettings.memory)
|
const memory = ref(props.instance.memory ?? globalSettings.memory)
|
||||||
const maxMemory = Math.floor((await get_max_memory().catch(handleError)) / 1024)
|
const { maxMemory, snapPoints } = await useMemorySlider()
|
||||||
|
|
||||||
const editProfileObject = computed(() => {
|
const editProfileObject = computed(() => {
|
||||||
const editProfile: {
|
const editProfile: {
|
||||||
@@ -156,6 +156,8 @@ const messages = defineMessages({
|
|||||||
:min="512"
|
:min="512"
|
||||||
:max="maxMemory"
|
:max="maxMemory"
|
||||||
:step="64"
|
:step="64"
|
||||||
|
:snap-points="snapPoints"
|
||||||
|
:snap-range="512"
|
||||||
unit="MB"
|
unit="MB"
|
||||||
/>
|
/>
|
||||||
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
PaintbrushIcon,
|
PaintbrushIcon,
|
||||||
GameIcon,
|
GameIcon,
|
||||||
CoffeeIcon,
|
CoffeeIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
SpinnerIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { TabbedModal } from '@modrinth/ui'
|
import { TabbedModal } from '@modrinth/ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
@@ -23,6 +25,23 @@ import { useTheming } from '@/store/state'
|
|||||||
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
|
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
import { get, set } from '@/helpers/settings.ts'
|
import { get, set } from '@/helpers/settings.ts'
|
||||||
|
// [AR] Imports
|
||||||
|
import { installState, getRemote, updateState } from '@/helpers/update.js'
|
||||||
|
|
||||||
|
const updateModalView = ref(null)
|
||||||
|
const updateRequestFailView = ref(null)
|
||||||
|
|
||||||
|
const initUpdateModal = async () => {
|
||||||
|
updateModalView.value.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
const initDownload = async () => {
|
||||||
|
updateModalView.value.hide()
|
||||||
|
const result = await getRemote(true);
|
||||||
|
if (!result) {
|
||||||
|
updateRequestFailView.value.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const themeStore = useTheming()
|
const themeStore = useTheming()
|
||||||
|
|
||||||
@@ -138,11 +157,10 @@ function devModeCount() {
|
|||||||
{{ formatMessage(developerModeEnabled) }}
|
{{ formatMessage(developerModeEnabled) }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
|
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
|
||||||
:class="{ 'text-brand': themeStore.devMode, 'text-secondary': !themeStore.devMode }"
|
:class="{ 'text-brand': themeStore.devMode, 'text-secondary': !themeStore.devMode }"
|
||||||
@click="devModeCount"
|
@click="devModeCount">
|
||||||
>
|
|
||||||
<AstralRinthLogo class="w-6 h-6" />
|
<AstralRinthLogo class="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
@@ -153,9 +171,80 @@ function devModeCount() {
|
|||||||
{{ osVersion }}
|
{{ osVersion }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="updateState" class="w-8 h-8 cursor-pointer hover:brightness-75 neon-icon pulse">
|
||||||
|
<template v-if="installState">
|
||||||
|
<SpinnerIcon class="size-6 animate-spin" v-tooltip.bottom="'Installing in process...'" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<DownloadIcon class="size-6" v-tooltip.bottom="'View update info'" @click="!installState && (initUpdateModal(), getRemote(false))" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</TabbedModal>
|
</TabbedModal>
|
||||||
|
<!-- [AR] Feature -->
|
||||||
|
<ModalWrapper ref="updateModalView" :has-to-type="false" header="Request to update the AstralRinth launcher">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p>The new version of the AstralRinth launcher is available.</p>
|
||||||
|
<p>Your version is outdated. We recommend that you update to the latest version.</p>
|
||||||
|
<p><strong>⚠️ Warning ⚠️</strong></p>
|
||||||
|
<p>
|
||||||
|
Before updating, make sure that you have saved all running instances and made a backup copy of the instances
|
||||||
|
that are valuable to you. Remember that the authors of the product are not responsible for the breakdown of
|
||||||
|
your files, so you should always make copies of them and keep them in a safe place.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-secondary space-y-1">
|
||||||
|
<a class="neon-text" href="https://me.astralium.su/get/ar" target="_blank"
|
||||||
|
rel="noopener noreferrer"><strong>Source:</strong> Git Astralium</a>
|
||||||
|
<p>
|
||||||
|
<strong>Version on remote server:</strong>
|
||||||
|
<span id="releaseData" class="neon-text"></span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Version on local device:</strong>
|
||||||
|
<span class="neon-text">v{{ version }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute bottom-4 right-4 flex items-center gap-4 neon-button neon">
|
||||||
|
<Button class="bordered" @click="updateModalView.hide()">Cancel</Button>
|
||||||
|
<Button class="bordered" @click="initDownload()">Download file</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
|
<ModalWrapper ref="updateRequestFailView" :has-to-type="false" header="Failed to request a file from the server :(">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p><strong>Error occurred</strong></p>
|
||||||
|
<p>Unfortunately, the program was unable to download the file from our servers.</p>
|
||||||
|
<p>
|
||||||
|
Please try downloading it yourself from
|
||||||
|
<a class="neon-text" href="https://me.astralium.su/get/ar" target="_blank" rel="noopener noreferrer">Git
|
||||||
|
Astralium</a>
|
||||||
|
if there are any updates available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-secondary">
|
||||||
|
<p>
|
||||||
|
<strong>Local AstralRinth:</strong>
|
||||||
|
<span class="neon-text">v{{ version }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute bottom-4 right-4 flex items-center gap-4 neon-button neon">
|
||||||
|
<Button class="bordered" @click="updateRequestFailView.hide()">Close</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalWrapper>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '../../../../../../packages/assets/styles/neon-icon.scss';
|
||||||
|
@import '../../../../../../packages/assets/styles/neon-button.scss';
|
||||||
|
@import '../../../../../../packages/assets/styles/neon-text.scss';
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
onFlowCancel: {
|
||||||
|
type: Function,
|
||||||
|
default() {
|
||||||
|
return async () => {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const modal = ref()
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
modal.value.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
modal.value.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show, hide })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<ModalWrapper ref="modal" @hide="onFlowCancel">
|
||||||
|
<template #title>
|
||||||
|
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
|
||||||
|
<LogInIcon /> Sign in
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex justify-center gap-2">
|
||||||
|
<SpinnerIcon class="w-12 h-12 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
|
Please sign in at the browser window that just opened to continue.
|
||||||
|
</p>
|
||||||
|
</ModalWrapper>
|
||||||
|
</template>
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { get, set } from '@/helpers/settings.ts'
|
import { get, set } from '@/helpers/settings.ts'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { get_max_memory } from '@/helpers/jre'
|
|
||||||
import { handleError } from '@/store/notifications'
|
|
||||||
import { Slider, Toggle } from '@modrinth/ui'
|
import { Slider, Toggle } from '@modrinth/ui'
|
||||||
|
import useMemorySlider from '@/composables/useMemorySlider'
|
||||||
|
|
||||||
const fetchSettings = await get()
|
const fetchSettings = await get()
|
||||||
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
|
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
|
||||||
@@ -11,7 +10,7 @@ fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).jo
|
|||||||
|
|
||||||
const settings = ref(fetchSettings)
|
const settings = ref(fetchSettings)
|
||||||
|
|
||||||
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
|
const { maxMemory, snapPoints } = await useMemorySlider()
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
settings,
|
settings,
|
||||||
@@ -107,6 +106,8 @@ watch(
|
|||||||
:min="512"
|
:min="512"
|
||||||
:max="maxMemory"
|
:max="maxMemory"
|
||||||
:step="64"
|
:step="64"
|
||||||
|
:snap-points="snapPoints"
|
||||||
|
:snap-range="512"
|
||||||
unit="MB"
|
unit="MB"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ watch(
|
|||||||
option, you opt out and ads will no longer be shown based on your interests.
|
option, you opt out and ads will no longer be shown based on your interests.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- AstralRinth disabled element by default -->
|
<!-- [AR] Patch. Disabled element by default -->
|
||||||
<Toggle id="personalized-ads" v-model="settings.personalized_ads" :disabled="!settings.personalized_ads" />
|
<Toggle id="personalized-ads" v-model="settings.personalized_ads" :disabled="!settings.personalized_ads" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ watch(
|
|||||||
longer be collected.
|
longer be collected.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- AstralRinth disabled element by default -->
|
<!-- [AR] Patch. Disabled element by default -->
|
||||||
<Toggle id="opt-out-analytics" v-model="settings.telemetry" :disabled="!settings.telemetry" />
|
<Toggle id="opt-out-analytics" v-model="settings.telemetry" :disabled="!settings.telemetry" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ import {
|
|||||||
type Cape,
|
type Cape,
|
||||||
type SkinModel,
|
type SkinModel,
|
||||||
get_normalized_skin_texture,
|
get_normalized_skin_texture,
|
||||||
|
determineModelType,
|
||||||
} from '@/helpers/skins.ts'
|
} from '@/helpers/skins.ts'
|
||||||
import { handleError } from '@/store/notifications'
|
import { handleError } from '@/store/notifications'
|
||||||
import {
|
import {
|
||||||
@@ -253,7 +254,7 @@ async function showNew(e: MouseEvent, skinTextureUrl: string) {
|
|||||||
mode.value = 'new'
|
mode.value = 'new'
|
||||||
currentSkin.value = null
|
currentSkin.value = null
|
||||||
uploadedTextureUrl.value = skinTextureUrl
|
uploadedTextureUrl.value = skinTextureUrl
|
||||||
variant.value = 'CLASSIC'
|
variant.value = await determineModelType(skinTextureUrl)
|
||||||
selectedCape.value = undefined
|
selectedCape.value = undefined
|
||||||
visibleCapeList.value = []
|
visibleCapeList.value = []
|
||||||
initVisibleCapeList()
|
initVisibleCapeList()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
|
type ProtocolVersion,
|
||||||
type ServerWorld,
|
type ServerWorld,
|
||||||
type ServerData,
|
type ServerData,
|
||||||
type WorldWithProfile,
|
type WorldWithProfile,
|
||||||
@@ -33,7 +34,7 @@ const theme = useTheming()
|
|||||||
|
|
||||||
const jumpBackInItems = ref<JumpBackInItem[]>([])
|
const jumpBackInItems = ref<JumpBackInItem[]>([])
|
||||||
const serverData = ref<Record<string, ServerData>>({})
|
const serverData = ref<Record<string, ServerData>>({})
|
||||||
const protocolVersions = ref<Record<string, number | null>>({})
|
const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
|
||||||
|
|
||||||
const MIN_JUMP_BACK_IN = 3
|
const MIN_JUMP_BACK_IN = 3
|
||||||
const MAX_JUMP_BACK_IN = 6
|
const MAX_JUMP_BACK_IN = 6
|
||||||
@@ -121,11 +122,8 @@ async function populateJumpBackIn() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// fetch each server's data
|
servers.forEach(({ instancePath, address }) =>
|
||||||
Promise.all(
|
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
|
||||||
servers.map(({ instancePath, address }) =>
|
|
||||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +148,8 @@ async function populateJumpBackIn() {
|
|||||||
.slice(0, MAX_JUMP_BACK_IN)
|
.slice(0, MAX_JUMP_BACK_IN)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshServer(address: string, instancePath: string) {
|
function refreshServer(address: string, instancePath: string) {
|
||||||
await refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
|
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function joinWorld(world: WorldWithProfile) {
|
async function joinWorld(world: WorldWithProfile) {
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts'
|
import type {
|
||||||
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
|
ProtocolVersion,
|
||||||
|
ServerStatus,
|
||||||
|
ServerWorld,
|
||||||
|
SingleplayerWorld,
|
||||||
|
World,
|
||||||
|
set_world_display_status,
|
||||||
|
getWorldIdentifier,
|
||||||
|
} from '@/helpers/worlds.ts'
|
||||||
import { formatNumber, getPingLevel } from '@modrinth/utils'
|
import { formatNumber, getPingLevel } from '@modrinth/utils'
|
||||||
import {
|
import {
|
||||||
useRelativeTime,
|
useRelativeTime,
|
||||||
@@ -55,7 +62,7 @@ const props = withDefaults(
|
|||||||
playingWorld?: boolean
|
playingWorld?: boolean
|
||||||
startingInstance?: boolean
|
startingInstance?: boolean
|
||||||
supportsQuickPlay?: boolean
|
supportsQuickPlay?: boolean
|
||||||
currentProtocol?: number | null
|
currentProtocol?: ProtocolVersion | null
|
||||||
highlighted?: boolean
|
highlighted?: boolean
|
||||||
|
|
||||||
// Server only
|
// Server only
|
||||||
@@ -102,7 +109,8 @@ const serverIncompatible = computed(
|
|||||||
!!props.serverStatus &&
|
!!props.serverStatus &&
|
||||||
!!props.serverStatus.version?.protocol &&
|
!!props.serverStatus.version?.protocol &&
|
||||||
!!props.currentProtocol &&
|
!!props.currentProtocol &&
|
||||||
props.serverStatus.version.protocol !== props.currentProtocol,
|
(props.serverStatus.version.protocol !== props.currentProtocol.version ||
|
||||||
|
props.serverStatus.version.legacy !== props.currentProtocol.legacy),
|
||||||
)
|
)
|
||||||
|
|
||||||
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
||||||
@@ -128,6 +136,14 @@ const messages = defineMessages({
|
|||||||
id: 'instance.worlds.game_already_open',
|
id: 'instance.worlds.game_already_open',
|
||||||
defaultMessage: 'Instance is already open',
|
defaultMessage: 'Instance is already open',
|
||||||
},
|
},
|
||||||
|
noContact: {
|
||||||
|
id: 'instance.worlds.no_contact',
|
||||||
|
defaultMessage: "Server couldn't be contacted",
|
||||||
|
},
|
||||||
|
incompatibleServer: {
|
||||||
|
id: 'instance.worlds.incompatible_server',
|
||||||
|
defaultMessage: 'Server is incompatible',
|
||||||
|
},
|
||||||
copyAddress: {
|
copyAddress: {
|
||||||
id: 'instance.worlds.copy_address',
|
id: 'instance.worlds.copy_address',
|
||||||
defaultMessage: 'Copy address',
|
defaultMessage: 'Copy address',
|
||||||
@@ -302,39 +318,33 @@ const messages = defineMessages({
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
||||||
<template v-if="world.type === 'singleplayer' || serverStatus">
|
<ButtonStyled
|
||||||
<ButtonStyled
|
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
||||||
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
color="red"
|
||||||
color="red"
|
>
|
||||||
>
|
<button @click="emit('stop')">
|
||||||
<button @click="emit('stop')">
|
<StopCircleIcon aria-hidden="true" />
|
||||||
<StopCircleIcon aria-hidden="true" />
|
{{ formatMessage(commonMessages.stopButton) }}
|
||||||
{{ formatMessage(commonMessages.stopButton) }}
|
</button>
|
||||||
</button>
|
</ButtonStyled>
|
||||||
</ButtonStyled>
|
<ButtonStyled v-else>
|
||||||
<ButtonStyled v-else>
|
<button
|
||||||
<button
|
v-tooltip="
|
||||||
v-tooltip="
|
!serverStatus
|
||||||
serverIncompatible
|
? formatMessage(messages.noContact)
|
||||||
? 'Server is incompatible'
|
: serverIncompatible
|
||||||
|
? formatMessage(messages.incompatibleServer)
|
||||||
: !supportsQuickPlay
|
: !supportsQuickPlay
|
||||||
? formatMessage(messages.noQuickPlay)
|
? formatMessage(messages.noQuickPlay)
|
||||||
: playingOtherWorld || locked
|
: playingOtherWorld || locked
|
||||||
? formatMessage(messages.gameAlreadyOpen)
|
? formatMessage(messages.gameAlreadyOpen)
|
||||||
: null
|
: null
|
||||||
"
|
"
|
||||||
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
||||||
@click="emit('play')"
|
@click="emit('play')"
|
||||||
>
|
>
|
||||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||||
<PlayIcon v-else aria-hidden="true" />
|
<PlayIcon v-else aria-hidden="true" />
|
||||||
{{ formatMessage(commonMessages.playButton) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
<ButtonStyled v-else>
|
|
||||||
<button class="invisible">
|
|
||||||
<PlayIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(commonMessages.playButton) }}
|
{{ formatMessage(commonMessages.playButton) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
|
|||||||
21
apps/app-frontend/src/composables/useMemorySlider.js
Normal file
21
apps/app-frontend/src/composables/useMemorySlider.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { get_max_memory } from '@/helpers/jre.js'
|
||||||
|
import { handleError } from '@/store/notifications.js'
|
||||||
|
|
||||||
|
export default async function () {
|
||||||
|
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
|
||||||
|
|
||||||
|
const snapPoints = computed(() => {
|
||||||
|
let points = []
|
||||||
|
let memory = 2048
|
||||||
|
|
||||||
|
while (memory <= maxMemory.value) {
|
||||||
|
points.push(memory)
|
||||||
|
memory *= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return points
|
||||||
|
})
|
||||||
|
|
||||||
|
return { maxMemory, snapPoints }
|
||||||
|
}
|
||||||
@@ -17,6 +17,24 @@ export async function offline_login(name) {
|
|||||||
return await invoke('plugin:auth|offline_login', { name: name })
|
return await invoke('plugin:auth|offline_login', { name: name })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [AR] • Feature
|
||||||
|
export async function elyby_login(uuid, login, accessToken) {
|
||||||
|
return await invoke('plugin:auth|elyby_login', {
|
||||||
|
uuid,
|
||||||
|
login,
|
||||||
|
accessToken
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// [AR] • Feature
|
||||||
|
export async function elyby_auth_authenticate(login, password, clientToken) {
|
||||||
|
return await invoke('plugin:auth|elyby_auth_authenticate', {
|
||||||
|
login,
|
||||||
|
password,
|
||||||
|
clientToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate a user with Hydra - part 1.
|
* Authenticate a user with Hydra - part 1.
|
||||||
* This begins the authentication flow quasi-synchronously.
|
* This begins the authentication flow quasi-synchronously.
|
||||||
|
|||||||
@@ -61,3 +61,31 @@ export async function is_valid_importable_instance(instanceFolder, launcherType)
|
|||||||
export async function get_default_launcher_path(launcherType) {
|
export async function get_default_launcher_path(launcherType) {
|
||||||
return await invoke('plugin:import|get_default_launcher_path', { launcherType })
|
return await invoke('plugin:import|get_default_launcher_path', { launcherType })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch CurseForge profile metadata from profile code
|
||||||
|
/// eg: fetch_curseforge_profile_metadata("eSrNlKNo")
|
||||||
|
export async function fetch_curseforge_profile_metadata(profileCode) {
|
||||||
|
return await invoke('plugin:import|fetch_curseforge_profile_metadata', { profileCode })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a CurseForge profile from profile code
|
||||||
|
/// eg: import_curseforge_profile("eSrNlKNo")
|
||||||
|
export async function import_curseforge_profile(profileCode) {
|
||||||
|
try {
|
||||||
|
// First, fetch the profile metadata to get the actual name
|
||||||
|
const metadata = await fetch_curseforge_profile_metadata(profileCode)
|
||||||
|
|
||||||
|
// create a basic, empty instance using the actual profile name
|
||||||
|
const profilePath = await create(metadata.name, '1.19.4', 'vanilla', 'latest', null, true)
|
||||||
|
|
||||||
|
const result = await invoke('plugin:import|import_curseforge_profile', {
|
||||||
|
profilePath,
|
||||||
|
profileCode,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return the profile path for navigation
|
||||||
|
return { result, profilePath }
|
||||||
|
} catch (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export async function get_jre(path) {
|
|||||||
|
|
||||||
// Tests JRE version by running 'java -version' on it.
|
// Tests JRE version by running 'java -version' on it.
|
||||||
// Returns true if the version is valid, and matches given (after extraction)
|
// Returns true if the version is valid, and matches given (after extraction)
|
||||||
export async function test_jre(path, majorVersion, minorVersion) {
|
export async function test_jre(path, majorVersion) {
|
||||||
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion, minorVersion })
|
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically installs specified java version
|
// Automatically installs specified java version
|
||||||
|
|||||||
@@ -16,3 +16,7 @@ export async function logout() {
|
|||||||
export async function get() {
|
export async function get() {
|
||||||
return await invoke('plugin:mr-auth|get')
|
return await invoke('plugin:mr-auth|get')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cancelLogin() {
|
||||||
|
return await invoke('plugin:mr-auth|cancel_modrinth_login')
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,25 +2,46 @@ import * as THREE from 'three'
|
|||||||
import type { Skin, Cape } from '../skins'
|
import type { Skin, Cape } from '../skins'
|
||||||
import { get_normalized_skin_texture, determineModelType } from '../skins'
|
import { get_normalized_skin_texture, determineModelType } from '../skins'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { setupSkinModel, disposeCaches } from '@modrinth/utils'
|
import {
|
||||||
|
setupSkinModel,
|
||||||
|
disposeCaches,
|
||||||
|
loadTexture,
|
||||||
|
applyCapeTexture,
|
||||||
|
createTransparentTexture,
|
||||||
|
} from '@modrinth/utils'
|
||||||
import { skinPreviewStorage } from '../storage/skin-preview-storage'
|
import { skinPreviewStorage } from '../storage/skin-preview-storage'
|
||||||
import { CapeModel, ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
|
import { headStorage } from '../storage/head-storage'
|
||||||
|
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
|
||||||
|
|
||||||
export interface RenderResult {
|
export interface RenderResult {
|
||||||
forwards: string
|
forwards: string
|
||||||
backwards: string
|
backwards: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RawRenderResult {
|
||||||
|
forwards: Blob
|
||||||
|
backwards: Blob
|
||||||
|
}
|
||||||
|
|
||||||
class BatchSkinRenderer {
|
class BatchSkinRenderer {
|
||||||
private renderer: THREE.WebGLRenderer
|
private renderer: THREE.WebGLRenderer | null = null
|
||||||
private readonly scene: THREE.Scene
|
private scene: THREE.Scene | null = null
|
||||||
private readonly camera: THREE.PerspectiveCamera
|
private camera: THREE.PerspectiveCamera | null = null
|
||||||
private currentModel: THREE.Group | null = null
|
private currentModel: THREE.Group | null = null
|
||||||
|
private readonly width: number
|
||||||
|
private readonly height: number
|
||||||
|
|
||||||
constructor(width: number = 360, height: number = 504) {
|
constructor(width: number = 360, height: number = 504) {
|
||||||
|
this.width = width
|
||||||
|
this.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRenderer(): void {
|
||||||
|
if (this.renderer) return
|
||||||
|
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
canvas.width = width
|
canvas.width = this.width
|
||||||
canvas.height = height
|
canvas.height = this.height
|
||||||
|
|
||||||
this.renderer = new THREE.WebGLRenderer({
|
this.renderer = new THREE.WebGLRenderer({
|
||||||
canvas: canvas,
|
canvas: canvas,
|
||||||
@@ -33,10 +54,10 @@ class BatchSkinRenderer {
|
|||||||
this.renderer.toneMapping = THREE.NoToneMapping
|
this.renderer.toneMapping = THREE.NoToneMapping
|
||||||
this.renderer.toneMappingExposure = 10.0
|
this.renderer.toneMappingExposure = 10.0
|
||||||
this.renderer.setClearColor(0x000000, 0)
|
this.renderer.setClearColor(0x000000, 0)
|
||||||
this.renderer.setSize(width, height)
|
this.renderer.setSize(this.width, this.height)
|
||||||
|
|
||||||
this.scene = new THREE.Scene()
|
this.scene = new THREE.Scene()
|
||||||
this.camera = new THREE.PerspectiveCamera(20, width / height, 0.4, 1000)
|
this.camera = new THREE.PerspectiveCamera(20, this.width / this.height, 0.4, 1000)
|
||||||
|
|
||||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||||
@@ -50,9 +71,12 @@ class BatchSkinRenderer {
|
|||||||
textureUrl: string,
|
textureUrl: string,
|
||||||
modelUrl: string,
|
modelUrl: string,
|
||||||
capeUrl?: string,
|
capeUrl?: string,
|
||||||
capeModelUrl?: string,
|
): Promise<RawRenderResult> {
|
||||||
): Promise<RenderResult> {
|
this.initializeRenderer()
|
||||||
await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
|
|
||||||
|
this.clearScene()
|
||||||
|
|
||||||
|
await this.setupModel(modelUrl, textureUrl, capeUrl)
|
||||||
|
|
||||||
const headPart = this.currentModel!.getObjectByName('Head')
|
const headPart = this.currentModel!.getObjectByName('Head')
|
||||||
let lookAtTarget: [number, number, number]
|
let lookAtTarget: [number, number, number]
|
||||||
@@ -77,35 +101,35 @@ class BatchSkinRenderer {
|
|||||||
private async renderView(
|
private async renderView(
|
||||||
cameraPosition: [number, number, number],
|
cameraPosition: [number, number, number],
|
||||||
lookAtPosition: [number, number, number],
|
lookAtPosition: [number, number, number],
|
||||||
): Promise<string> {
|
): Promise<Blob> {
|
||||||
|
if (!this.camera || !this.renderer || !this.scene) {
|
||||||
|
throw new Error('Renderer not initialized')
|
||||||
|
}
|
||||||
|
|
||||||
this.camera.position.set(...cameraPosition)
|
this.camera.position.set(...cameraPosition)
|
||||||
this.camera.lookAt(...lookAtPosition)
|
this.camera.lookAt(...lookAtPosition)
|
||||||
|
|
||||||
this.renderer.render(this.scene, this.camera)
|
this.renderer.render(this.scene, this.camera)
|
||||||
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9)
|
||||||
this.renderer.domElement.toBlob((blob) => {
|
const response = await fetch(dataUrl)
|
||||||
if (blob) {
|
return await response.blob()
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
resolve(url)
|
|
||||||
} else {
|
|
||||||
reject(new Error('Failed to create blob from canvas'))
|
|
||||||
}
|
|
||||||
}, 'image/png')
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setupModel(
|
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
|
||||||
modelUrl: string,
|
if (!this.scene) {
|
||||||
textureUrl: string,
|
throw new Error('Renderer not initialized')
|
||||||
capeModelUrl?: string,
|
|
||||||
capeUrl?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
if (this.currentModel) {
|
|
||||||
this.scene.remove(this.currentModel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
|
const { model } = await setupSkinModel(modelUrl, textureUrl)
|
||||||
|
|
||||||
|
if (capeUrl) {
|
||||||
|
const capeTexture = await loadTexture(capeUrl)
|
||||||
|
applyCapeTexture(model, capeTexture)
|
||||||
|
} else {
|
||||||
|
const transparentTexture = createTransparentTexture()
|
||||||
|
applyCapeTexture(model, null, transparentTexture)
|
||||||
|
}
|
||||||
|
|
||||||
const group = new THREE.Group()
|
const group = new THREE.Group()
|
||||||
group.add(model)
|
group.add(model)
|
||||||
@@ -116,8 +140,39 @@ class BatchSkinRenderer {
|
|||||||
this.currentModel = group
|
this.currentModel = group
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearScene(): void {
|
||||||
|
if (!this.scene) return
|
||||||
|
|
||||||
|
while (this.scene.children.length > 0) {
|
||||||
|
const child = this.scene.children[0]
|
||||||
|
this.scene.remove(child)
|
||||||
|
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
if (child.geometry) child.geometry.dispose()
|
||||||
|
if (child.material) {
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material.forEach((material) => material.dispose())
|
||||||
|
} else {
|
||||||
|
child.material.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||||
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||||
|
directionalLight.castShadow = true
|
||||||
|
directionalLight.position.set(2, 4, 3)
|
||||||
|
this.scene.add(ambientLight)
|
||||||
|
this.scene.add(directionalLight)
|
||||||
|
|
||||||
|
this.currentModel = null
|
||||||
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.renderer.dispose()
|
if (this.renderer) {
|
||||||
|
this.renderer.dispose()
|
||||||
|
}
|
||||||
disposeCaches()
|
disposeCaches()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,10 +188,25 @@ function getModelUrlForVariant(variant: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const map = reactive(new Map<string, RenderResult>())
|
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
|
||||||
export const headMap = reactive(new Map<string, string>())
|
export const headBlobUrlMap = reactive(new Map<string, string>())
|
||||||
const DEBUG_MODE = false
|
const DEBUG_MODE = false
|
||||||
|
|
||||||
|
let sharedRenderer: BatchSkinRenderer | null = null
|
||||||
|
function getSharedRenderer(): BatchSkinRenderer {
|
||||||
|
if (!sharedRenderer) {
|
||||||
|
sharedRenderer = new BatchSkinRenderer()
|
||||||
|
}
|
||||||
|
return sharedRenderer
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disposeSharedRenderer(): void {
|
||||||
|
if (sharedRenderer) {
|
||||||
|
sharedRenderer.dispose()
|
||||||
|
sharedRenderer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
|
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
|
||||||
const validKeys = new Set<string>()
|
const validKeys = new Set<string>()
|
||||||
const validHeadKeys = new Set<string>()
|
const validHeadKeys = new Set<string>()
|
||||||
@@ -150,7 +220,7 @@ export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await skinPreviewStorage.cleanupInvalidKeys(validKeys)
|
await skinPreviewStorage.cleanupInvalidKeys(validKeys)
|
||||||
await skinPreviewStorage.cleanupInvalidKeys(validHeadKeys)
|
await headStorage.cleanupInvalidKeys(validHeadKeys)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to cleanup unused skin previews:', error)
|
console.warn('Failed to cleanup unused skin previews:', error)
|
||||||
}
|
}
|
||||||
@@ -229,13 +299,17 @@ export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64)
|
|||||||
outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size)
|
outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
outputCanvas.toBlob((blob) => {
|
outputCanvas.toBlob(
|
||||||
if (blob) {
|
(blob) => {
|
||||||
resolve(blob)
|
if (blob) {
|
||||||
} else {
|
resolve(blob)
|
||||||
reject(new Error('Failed to create blob from canvas'))
|
} else {
|
||||||
}
|
reject(new Error('Failed to create blob from canvas'))
|
||||||
}, 'image/png')
|
}
|
||||||
|
},
|
||||||
|
'image/webp',
|
||||||
|
0.9,
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error)
|
reject(error)
|
||||||
}
|
}
|
||||||
@@ -252,35 +326,24 @@ export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64)
|
|||||||
async function generateHeadRender(skin: Skin): Promise<string> {
|
async function generateHeadRender(skin: Skin): Promise<string> {
|
||||||
const headKey = `${skin.texture_key}-head`
|
const headKey = `${skin.texture_key}-head`
|
||||||
|
|
||||||
if (headMap.has(headKey)) {
|
if (headBlobUrlMap.has(headKey)) {
|
||||||
if (DEBUG_MODE) {
|
if (DEBUG_MODE) {
|
||||||
const url = headMap.get(headKey)!
|
const url = headBlobUrlMap.get(headKey)!
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
headMap.delete(headKey)
|
headBlobUrlMap.delete(headKey)
|
||||||
} else {
|
} else {
|
||||||
return headMap.get(headKey)!
|
return headBlobUrlMap.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 skinUrl = await get_normalized_skin_texture(skin)
|
||||||
const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
|
const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
|
||||||
const headUrl = URL.createObjectURL(headBlob)
|
const headUrl = URL.createObjectURL(headBlob)
|
||||||
|
|
||||||
headMap.set(headKey, headUrl)
|
headBlobUrlMap.set(headKey, headUrl)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// @ts-expect-error - skinPreviewStorage.store expects a RenderResult, but we are storing a string url.
|
await headStorage.store(headKey, headBlob)
|
||||||
await skinPreviewStorage.store(headKey, headUrl)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to store head render in persistent storage:', error)
|
console.warn('Failed to store head render in persistent storage:', error)
|
||||||
}
|
}
|
||||||
@@ -293,30 +356,49 @@ export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
|
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
|
||||||
const renderer = new BatchSkinRenderer()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const skinKeys = skins.map(
|
||||||
|
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
|
||||||
|
)
|
||||||
|
const headKeys = skins.map((skin) => `${skin.texture_key}-head`)
|
||||||
|
|
||||||
|
const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([
|
||||||
|
skinPreviewStorage.batchRetrieve(skinKeys),
|
||||||
|
headStorage.batchRetrieve(headKeys),
|
||||||
|
])
|
||||||
|
|
||||||
|
for (let i = 0; i < skins.length; i++) {
|
||||||
|
const skinKey = skinKeys[i]
|
||||||
|
const headKey = headKeys[i]
|
||||||
|
|
||||||
|
const rawCached = cachedSkinPreviews[skinKey]
|
||||||
|
if (rawCached) {
|
||||||
|
const cached: RenderResult = {
|
||||||
|
forwards: URL.createObjectURL(rawCached.forwards),
|
||||||
|
backwards: URL.createObjectURL(rawCached.backwards),
|
||||||
|
}
|
||||||
|
skinBlobUrlMap.set(skinKey, cached)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedHead = cachedHeadPreviews[headKey]
|
||||||
|
if (cachedHead) {
|
||||||
|
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const skin of skins) {
|
for (const skin of skins) {
|
||||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||||
|
|
||||||
if (map.has(key)) {
|
if (skinBlobUrlMap.has(key)) {
|
||||||
if (DEBUG_MODE) {
|
if (DEBUG_MODE) {
|
||||||
const result = map.get(key)!
|
const result = skinBlobUrlMap.get(key)!
|
||||||
URL.revokeObjectURL(result.forwards)
|
URL.revokeObjectURL(result.forwards)
|
||||||
URL.revokeObjectURL(result.backwards)
|
URL.revokeObjectURL(result.backwards)
|
||||||
map.delete(key)
|
skinBlobUrlMap.delete(key)
|
||||||
} else continue
|
} else continue
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const renderer = getSharedRenderer()
|
||||||
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
|
let variant = skin.variant
|
||||||
if (variant === 'UNKNOWN') {
|
if (variant === 'UNKNOWN') {
|
||||||
@@ -330,25 +412,35 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
|
|||||||
|
|
||||||
const modelUrl = getModelUrlForVariant(variant)
|
const modelUrl = getModelUrlForVariant(variant)
|
||||||
const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
|
const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
|
||||||
const renderResult = await renderer.renderSkin(
|
const rawRenderResult = await renderer.renderSkin(
|
||||||
await get_normalized_skin_texture(skin),
|
await get_normalized_skin_texture(skin),
|
||||||
modelUrl,
|
modelUrl,
|
||||||
cape?.texture,
|
cape?.texture,
|
||||||
CapeModel,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
map.set(key, renderResult)
|
const renderResult: RenderResult = {
|
||||||
|
forwards: URL.createObjectURL(rawRenderResult.forwards),
|
||||||
|
backwards: URL.createObjectURL(rawRenderResult.backwards),
|
||||||
|
}
|
||||||
|
|
||||||
|
skinBlobUrlMap.set(key, renderResult)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await skinPreviewStorage.store(key, renderResult)
|
await skinPreviewStorage.store(key, rawRenderResult)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to store skin preview in persistent storage:', error)
|
console.warn('Failed to store skin preview in persistent storage:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
await generateHeadRender(skin)
|
const headKey = `${skin.texture_key}-head`
|
||||||
|
if (!headBlobUrlMap.has(headKey)) {
|
||||||
|
await generateHeadRender(skin)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
renderer.dispose()
|
disposeSharedRenderer()
|
||||||
await cleanupUnusedPreviews(skins)
|
await cleanupUnusedPreviews(skins)
|
||||||
|
|
||||||
|
await skinPreviewStorage.debugCalculateStorage()
|
||||||
|
await headStorage.debugCalculateStorage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,15 +62,12 @@ export async function determineModelType(texture: string): Promise<'SLIM' | 'CLA
|
|||||||
|
|
||||||
context.drawImage(image, 0, 0)
|
context.drawImage(image, 0, 0)
|
||||||
|
|
||||||
const armX = 44
|
const armX = 54
|
||||||
const armY = 16
|
const armY = 20
|
||||||
const armWidth = 4
|
const armWidth = 2
|
||||||
const armHeight = 12
|
const armHeight = 12
|
||||||
|
|
||||||
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
|
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
|
||||||
|
for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) {
|
||||||
for (let y = 0; y < armHeight; y++) {
|
|
||||||
const alphaIndex = (3 + y * armWidth) * 4 + 3
|
|
||||||
if (imageData[alphaIndex] !== 0) {
|
if (imageData[alphaIndex] !== 0) {
|
||||||
resolve('CLASSIC')
|
resolve('CLASSIC')
|
||||||
return
|
return
|
||||||
|
|||||||
229
apps/app-frontend/src/helpers/storage/head-storage.ts
Normal file
229
apps/app-frontend/src/helpers/storage/head-storage.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
interface StoredHead {
|
||||||
|
blob: Blob
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HeadStorage {
|
||||||
|
private dbName = 'head-storage'
|
||||||
|
private version = 1
|
||||||
|
private db: IDBDatabase | null = null
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(this.dbName, this.version)
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
request.onsuccess = () => {
|
||||||
|
this.db = request.result
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result
|
||||||
|
if (!db.objectStoreNames.contains('heads')) {
|
||||||
|
db.createObjectStore('heads')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async store(key: string, blob: Blob): Promise<void> {
|
||||||
|
if (!this.db) await this.init()
|
||||||
|
|
||||||
|
const transaction = this.db!.transaction(['heads'], 'readwrite')
|
||||||
|
const store = transaction.objectStore('heads')
|
||||||
|
|
||||||
|
const storedHead: StoredHead = {
|
||||||
|
blob,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.put(storedHead, key)
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve()
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieve(key: string): Promise<string | null> {
|
||||||
|
if (!this.db) await this.init()
|
||||||
|
|
||||||
|
const transaction = this.db!.transaction(['heads'], 'readonly')
|
||||||
|
const store = transaction.objectStore('heads')
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.get(key)
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const result = request.result as StoredHead | undefined
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
resolve(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(result.blob)
|
||||||
|
resolve(url)
|
||||||
|
}
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchRetrieve(keys: string[]): Promise<Record<string, Blob | null>> {
|
||||||
|
if (!this.db) await this.init()
|
||||||
|
|
||||||
|
const transaction = this.db!.transaction(['heads'], 'readonly')
|
||||||
|
const store = transaction.objectStore('heads')
|
||||||
|
const results: Record<string, Blob | null> = {}
|
||||||
|
|
||||||
|
return new Promise((resolve, _reject) => {
|
||||||
|
let completedRequests = 0
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
resolve(results)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const request = store.get(key)
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const result = request.result as StoredHead | undefined
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
results[key] = result.blob
|
||||||
|
} else {
|
||||||
|
results[key] = null
|
||||||
|
}
|
||||||
|
|
||||||
|
completedRequests++
|
||||||
|
if (completedRequests === keys.length) {
|
||||||
|
resolve(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
results[key] = null
|
||||||
|
completedRequests++
|
||||||
|
if (completedRequests === keys.length) {
|
||||||
|
resolve(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
|
||||||
|
if (!this.db) await this.init()
|
||||||
|
|
||||||
|
const transaction = this.db!.transaction(['heads'], 'readwrite')
|
||||||
|
const store = transaction.objectStore('heads')
|
||||||
|
let deletedCount = 0
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.openCursor()
|
||||||
|
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
const key = cursor.primaryKey as string
|
||||||
|
|
||||||
|
if (!validKeys.has(key)) {
|
||||||
|
const deleteRequest = cursor.delete()
|
||||||
|
deleteRequest.onsuccess = () => {
|
||||||
|
deletedCount++
|
||||||
|
}
|
||||||
|
deleteRequest.onerror = () => {
|
||||||
|
console.warn('Failed to delete invalid head entry:', key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
resolve(deletedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async debugCalculateStorage(): Promise<void> {
|
||||||
|
if (!this.db) await this.init()
|
||||||
|
|
||||||
|
const transaction = this.db!.transaction(['heads'], 'readonly')
|
||||||
|
const store = transaction.objectStore('heads')
|
||||||
|
|
||||||
|
let totalSize = 0
|
||||||
|
let count = 0
|
||||||
|
const entries: Array<{ key: string; size: number }> = []
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.openCursor()
|
||||||
|
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
const key = cursor.primaryKey as string
|
||||||
|
const value = cursor.value as StoredHead
|
||||||
|
|
||||||
|
const entrySize = value.blob.size
|
||||||
|
totalSize += entrySize
|
||||||
|
count++
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
key,
|
||||||
|
size: entrySize,
|
||||||
|
})
|
||||||
|
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
console.group('🗄️ Head Storage Debug Info')
|
||||||
|
console.log(`Total entries: ${count}`)
|
||||||
|
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
|
||||||
|
console.log(
|
||||||
|
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (entries.length > 0) {
|
||||||
|
const sortedEntries = entries.sort((a, b) => b.size - a.size)
|
||||||
|
console.log(
|
||||||
|
'Largest entry:',
|
||||||
|
sortedEntries[0].key,
|
||||||
|
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
|
||||||
|
)
|
||||||
|
console.log(
|
||||||
|
'Smallest entry:',
|
||||||
|
sortedEntries[sortedEntries.length - 1].key,
|
||||||
|
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.groupEnd()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAll(): Promise<void> {
|
||||||
|
if (!this.db) await this.init()
|
||||||
|
|
||||||
|
const transaction = this.db!.transaction(['heads'], 'readwrite')
|
||||||
|
const store = transaction.objectStore('heads')
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.clear()
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve()
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const headStorage = new HeadStorage()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RenderResult } from '../rendering/batch-skin-renderer'
|
import type { RawRenderResult } from '../rendering/batch-skin-renderer'
|
||||||
|
|
||||||
interface StoredPreview {
|
interface StoredPreview {
|
||||||
forwards: Blob
|
forwards: Blob
|
||||||
@@ -30,18 +30,15 @@ export class SkinPreviewStorage {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async store(key: string, result: RenderResult): Promise<void> {
|
async store(key: string, result: RawRenderResult): Promise<void> {
|
||||||
if (!this.db) await this.init()
|
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 transaction = this.db!.transaction(['previews'], 'readwrite')
|
||||||
const store = transaction.objectStore('previews')
|
const store = transaction.objectStore('previews')
|
||||||
|
|
||||||
const storedPreview: StoredPreview = {
|
const storedPreview: StoredPreview = {
|
||||||
forwards: forwardsBlob,
|
forwards: result.forwards,
|
||||||
backwards: backwardsBlob,
|
backwards: result.backwards,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +50,7 @@ export class SkinPreviewStorage {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async retrieve(key: string): Promise<RenderResult | null> {
|
async retrieve(key: string): Promise<RawRenderResult | null> {
|
||||||
if (!this.db) await this.init()
|
if (!this.db) await this.init()
|
||||||
|
|
||||||
const transaction = this.db!.transaction(['previews'], 'readonly')
|
const transaction = this.db!.transaction(['previews'], 'readonly')
|
||||||
@@ -70,14 +67,56 @@ export class SkinPreviewStorage {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const forwards = URL.createObjectURL(result.forwards)
|
resolve({ forwards: result.forwards, backwards: result.backwards })
|
||||||
const backwards = URL.createObjectURL(result.backwards)
|
|
||||||
resolve({ forwards, backwards })
|
|
||||||
}
|
}
|
||||||
request.onerror = () => reject(request.error)
|
request.onerror = () => reject(request.error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async batchRetrieve(keys: string[]): Promise<Record<string, RawRenderResult | null>> {
|
||||||
|
if (!this.db) await this.init()
|
||||||
|
|
||||||
|
const transaction = this.db!.transaction(['previews'], 'readonly')
|
||||||
|
const store = transaction.objectStore('previews')
|
||||||
|
const results: Record<string, RawRenderResult | null> = {}
|
||||||
|
|
||||||
|
return new Promise((resolve, _reject) => {
|
||||||
|
let completedRequests = 0
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
resolve(results)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const request = store.get(key)
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const result = request.result as StoredPreview | undefined
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
results[key] = { forwards: result.forwards, backwards: result.backwards }
|
||||||
|
} else {
|
||||||
|
results[key] = null
|
||||||
|
}
|
||||||
|
|
||||||
|
completedRequests++
|
||||||
|
if (completedRequests === keys.length) {
|
||||||
|
resolve(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
results[key] = null
|
||||||
|
completedRequests++
|
||||||
|
if (completedRequests === keys.length) {
|
||||||
|
resolve(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
|
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
|
||||||
if (!this.db) await this.init()
|
if (!this.db) await this.init()
|
||||||
|
|
||||||
@@ -113,6 +152,67 @@ export class SkinPreviewStorage {
|
|||||||
request.onerror = () => reject(request.error)
|
request.onerror = () => reject(request.error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async debugCalculateStorage(): Promise<void> {
|
||||||
|
if (!this.db) await this.init()
|
||||||
|
|
||||||
|
const transaction = this.db!.transaction(['previews'], 'readonly')
|
||||||
|
const store = transaction.objectStore('previews')
|
||||||
|
|
||||||
|
let totalSize = 0
|
||||||
|
let count = 0
|
||||||
|
const entries: Array<{ key: string; size: number }> = []
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.openCursor()
|
||||||
|
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
const key = cursor.primaryKey as string
|
||||||
|
const value = cursor.value as StoredPreview
|
||||||
|
|
||||||
|
const entrySize = value.forwards.size + value.backwards.size
|
||||||
|
totalSize += entrySize
|
||||||
|
count++
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
key,
|
||||||
|
size: entrySize,
|
||||||
|
})
|
||||||
|
|
||||||
|
cursor.continue()
|
||||||
|
} else {
|
||||||
|
console.group('🗄️ Skin Preview Storage Debug Info')
|
||||||
|
console.log(`Total entries: ${count}`)
|
||||||
|
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
|
||||||
|
console.log(
|
||||||
|
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (entries.length > 0) {
|
||||||
|
const sortedEntries = entries.sort((a, b) => b.size - a.size)
|
||||||
|
console.log(
|
||||||
|
'Largest entry:',
|
||||||
|
sortedEntries[0].key,
|
||||||
|
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
|
||||||
|
)
|
||||||
|
console.log(
|
||||||
|
'Smallest entry:',
|
||||||
|
sortedEntries[sortedEntries.length - 1].key,
|
||||||
|
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.groupEnd()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const skinPreviewStorage = new SkinPreviewStorage()
|
export const skinPreviewStorage = new SkinPreviewStorage()
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { getVersion } from '@tauri-apps/api/app'
|
import { getVersion } from '@tauri-apps/api/app'
|
||||||
import { getArtifact, getOS } from '@/helpers/utils.js'
|
import { initUpdateLauncher, getOS } from '@/helpers/utils.js'
|
||||||
|
|
||||||
|
|
||||||
export const allowState = ref(false)
|
export const allowState = ref(false)
|
||||||
export const installState = ref(false)
|
export const installState = ref(false)
|
||||||
export const updateState = ref(false)
|
export const updateState = ref(false)
|
||||||
export const latestBetaCommitTruncatedSha = ref('')
|
|
||||||
export const latestBetaCommitLink = ref('')
|
|
||||||
export const launcherUrl = 'https://www.astralium.su/get/ar'
|
|
||||||
|
|
||||||
const os = ref('')
|
const currentOS = ref('')
|
||||||
const releaseLink = `https://git.astralium.su/api/v1/repos/didirus/AstralRinth/releases/latest`
|
const releaseLink = `https://git.astralium.su/api/v1/repos/didirus/AstralRinth/releases/latest`
|
||||||
const failedFetch = [`Failed to fetch remote releases:`, `Failed to fetch remote commits:`]
|
const failedFetch = [`Failed to fetch remote releases:`, `Failed to fetch remote commits:`]
|
||||||
const osNames = ['macos', 'windows', 'linux']
|
|
||||||
const macExtension = `.dmg` // MacOS file type for download
|
const osList = ['macos', 'windows', 'linux']
|
||||||
const windowsExtension = `.msi` // Windows file type for download
|
const macExtensionList = ['.dmg', '.pkg']
|
||||||
const blacklistedBuilds = [
|
const windowsExtensionList = ['.exe', '.msi']
|
||||||
|
|
||||||
|
const blacklistPrefixes = [
|
||||||
`dev`,
|
`dev`,
|
||||||
`nightly`,
|
`nightly`,
|
||||||
`dirty`,
|
`dirty`,
|
||||||
@@ -26,110 +24,73 @@ const blacklistedBuilds = [
|
|||||||
`dirty_nightly`,
|
`dirty_nightly`,
|
||||||
] // This is blacklisted builds for download. For example, file.startsWith('dev') is not allowed.
|
] // This is blacklisted builds for download. For example, file.startsWith('dev') is not allowed.
|
||||||
|
|
||||||
/**
|
export async function getRemote(isDownloadState) {
|
||||||
* Asynchronous function to get remote data and handle updates and downloads.
|
var releaseData = null;
|
||||||
*
|
var result = false;
|
||||||
* @param {boolean} elementIdBool - Indicates whether to disable an element ID.
|
try {
|
||||||
* @param {boolean} downloadArtifactBool - Indicates whether to download an artifact.
|
const response = await fetch(releaseLink);
|
||||||
*/
|
if (!response.ok) {
|
||||||
export async function getRemote(elementIdBool, downloadArtifactBool) {
|
throw new Error(response.status);
|
||||||
fetch(releaseLink)
|
}
|
||||||
.then((response) => {
|
const remoteData = await response.json();
|
||||||
if (!response.ok) {
|
currentOS.value = await getOS();
|
||||||
throw new Error(response.status)
|
const remoteLatestReleaseTag = remoteData.tag_name;
|
||||||
}
|
releaseData = document.getElementById('releaseData');
|
||||||
return response.json()
|
const remoteVersion = releaseData ? (releaseData.textContent = remoteLatestReleaseTag) : remoteLatestReleaseTag;
|
||||||
})
|
|
||||||
.then(async (data) => {
|
|
||||||
os.value = await getOS()
|
|
||||||
const latestRelease = data.name
|
|
||||||
let remoteVersion = undefined
|
|
||||||
|
|
||||||
if (!elementIdBool) {
|
if (osList.includes(currentOS.value.toLowerCase())) {
|
||||||
const releaseData = document.getElementById('releaseData')
|
const localVersion = await getVersion();
|
||||||
if (releaseData == null) {
|
const isUpdateAvailable = !remoteVersion.includes(localVersion);
|
||||||
console.error('Release data element not found.')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
releaseData.textContent = latestRelease
|
|
||||||
remoteVersion = `${releaseData.textContent}`
|
|
||||||
} else {
|
|
||||||
remoteVersion = latestRelease
|
|
||||||
}
|
|
||||||
if (osNames.includes(os.value.toLowerCase())) {
|
|
||||||
if (remoteVersion.startsWith('v' + await getVersion())) {
|
|
||||||
updateState.value = false
|
|
||||||
allowState.value = false
|
|
||||||
} else {
|
|
||||||
updateState.value = true
|
|
||||||
allowState.value = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
updateState.value = false
|
|
||||||
allowState.value = false
|
|
||||||
}
|
|
||||||
console.log('Update available state is', updateState.value)
|
|
||||||
console.log('Remote version is', remoteVersion)
|
|
||||||
console.log('Local version is', await getVersion())
|
|
||||||
console.log('Operating System is', os.value)
|
|
||||||
|
|
||||||
if (downloadArtifactBool) {
|
updateState.value = isUpdateAvailable;
|
||||||
installState.value = true
|
allowState.value = isUpdateAvailable;
|
||||||
const builds = data.assets
|
} else {
|
||||||
const fileName = getInstaller(getExtension(), builds)
|
updateState.value = false;
|
||||||
if (fileName != null) {
|
allowState.value = false;
|
||||||
await getArtifact(fileName[1], fileName[0], os.value, true)
|
}
|
||||||
}
|
if (isDownloadState) {
|
||||||
installState.value = false
|
installState.value = true;
|
||||||
}
|
const builds = remoteData.assets;
|
||||||
})
|
const fileName = getInstaller(getExtension(), builds);
|
||||||
.catch((error) => {
|
result = fileName ? await initUpdateLauncher(fileName[1], fileName[0], currentOS.value, true) : false;
|
||||||
console.error(failedFetch[0], error)
|
installState.value = false;
|
||||||
if (!elementIdBool) {
|
}
|
||||||
const errorData = document.getElementById('releaseData')
|
|
||||||
if (errorData) {
|
|
||||||
errorData.textContent = `${error.message}`
|
|
||||||
}
|
|
||||||
updateState.value = false
|
|
||||||
allowState.value = false
|
|
||||||
installState.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
console.log('Update available state is', updateState.value);
|
||||||
* Retrieves the installer for a specific operating system.
|
console.log('Remote version is', remoteVersion);
|
||||||
*
|
console.log('Local version is', await getVersion());
|
||||||
* @param {string} osExtension - The file extension of the installer.
|
console.log('Operating System is', currentOS.value);
|
||||||
* @param {Array} builds - The list of builds.
|
return result;
|
||||||
* @return {Array|null} An array containing the installer name and URL if found, or null if not found.
|
} catch (error) {
|
||||||
*/
|
console.error(failedFetch[0], error);
|
||||||
function getInstaller(osExtension, builds) {
|
if (!releaseData) {
|
||||||
for (let i of builds) {
|
const errorData = document.getElementById('releaseData');
|
||||||
let blacklistedItem = false
|
if (errorData) {
|
||||||
blacklistedBuilds.forEach((item) => {
|
errorData.textContent = `${error.message}`;
|
||||||
if (i.name.startsWith(item)) {
|
|
||||||
return (blacklistedItem = true)
|
|
||||||
}
|
}
|
||||||
})
|
updateState.value = false;
|
||||||
if (i.name.endsWith(osExtension) && !blacklistedItem) {
|
allowState.value = false;
|
||||||
console.log(i.browser_download_url)
|
installState.value = false;
|
||||||
return [i.name, i.browser_download_url]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getInstaller(osExtension, builds) {
|
||||||
* A function to get the extension based on the operating system.
|
console.log(osExtension, builds)
|
||||||
*
|
for (const build of builds) {
|
||||||
* @return {string} The extension based on the operating system.
|
if (blacklistPrefixes.some(prefix => build.name.startsWith(prefix))) {
|
||||||
*/
|
continue;
|
||||||
function getExtension() {
|
}
|
||||||
if (os.value.toLowerCase() == osNames[0]) {
|
if (osExtension.some(ext => build.name.endsWith(ext))) {
|
||||||
return macExtension
|
console.log(build.name, build.browser_download_url);
|
||||||
} else if (os.value.toLowerCase() == osNames[1]) {
|
return [build.name, build.browser_download_url];
|
||||||
return windowsExtension
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getExtension() {
|
||||||
|
return osList.find(osName => osName === currentOS.value.toLowerCase())?.endsWith('macos')
|
||||||
|
? macExtensionList
|
||||||
|
: windowsExtensionList;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,20 @@ export async function getOS() {
|
|||||||
return await invoke('plugin:utils|get_os')
|
return await invoke('plugin:utils|get_os')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getArtifact(downloadurl, filename, ostype, autoupdatesupported) {
|
// [AR] Feature. Updater
|
||||||
console.log('Downloading build', downloadurl, filename, ostype, autoupdatesupported)
|
export async function initUpdateLauncher(downloadUrl, filename, osType, autoUpdateSupported) {
|
||||||
return await invoke('plugin:utils|get_artifact', { downloadurl, filename, ostype, autoupdatesupported })
|
console.log('Downloading build', downloadUrl, filename, osType, autoUpdateSupported)
|
||||||
|
return await invoke('plugin:utils|init_update_launcher', { downloadUrl, filename, osType, autoUpdateSupported })
|
||||||
|
}
|
||||||
|
|
||||||
|
// [AR] Migration. Patch
|
||||||
|
export async function applyMigrationFix(eol) {
|
||||||
|
return await invoke('plugin:utils|apply_migration_fix', { eol })
|
||||||
|
}
|
||||||
|
|
||||||
|
// [AR] Feature. Ely.by
|
||||||
|
export async function initAuthlibPatching(minecraftVersion, isMojang) {
|
||||||
|
return await invoke('plugin:utils|init_authlib_patching', { minecraftVersion, isMojang })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openPath(path) {
|
export async function openPath(path) {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export type ServerStatus = {
|
|||||||
version?: {
|
version?: {
|
||||||
name: string
|
name: string
|
||||||
protocol: number
|
protocol: number
|
||||||
|
legacy: boolean
|
||||||
}
|
}
|
||||||
favicon?: string
|
favicon?: string
|
||||||
enforces_secure_chat: boolean
|
enforces_secure_chat: boolean
|
||||||
@@ -70,11 +71,17 @@ export interface Chat {
|
|||||||
|
|
||||||
export type ServerData = {
|
export type ServerData = {
|
||||||
refreshing: boolean
|
refreshing: boolean
|
||||||
|
lastSuccessfulRefresh?: number
|
||||||
status?: ServerStatus
|
status?: ServerStatus
|
||||||
rawMotd?: string | Chat
|
rawMotd?: string | Chat
|
||||||
renderedMotd?: string
|
renderedMotd?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProtocolVersion = {
|
||||||
|
version: number
|
||||||
|
legacy: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export async function get_recent_worlds(
|
export async function get_recent_worlds(
|
||||||
limit: number,
|
limit: number,
|
||||||
displayStatuses?: DisplayStatus[],
|
displayStatuses?: DisplayStatus[],
|
||||||
@@ -156,13 +163,13 @@ export async function remove_server_from_profile(path: string, index: number): P
|
|||||||
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
|
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function get_profile_protocol_version(path: string): Promise<number | null> {
|
export async function get_profile_protocol_version(path: string): Promise<ProtocolVersion | null> {
|
||||||
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
|
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function get_server_status(
|
export async function get_server_status(
|
||||||
address: string,
|
address: string,
|
||||||
protocolVersion: number | null = null,
|
protocolVersion: ProtocolVersion | null = null,
|
||||||
): Promise<ServerStatus> {
|
): Promise<ServerStatus> {
|
||||||
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
|
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
|
||||||
}
|
}
|
||||||
@@ -206,30 +213,39 @@ export function isServerWorld(world: World): world is ServerWorld {
|
|||||||
|
|
||||||
export async function refreshServerData(
|
export async function refreshServerData(
|
||||||
serverData: ServerData,
|
serverData: ServerData,
|
||||||
protocolVersion: number | null,
|
protocolVersion: ProtocolVersion | null,
|
||||||
address: string,
|
address: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const refreshTime = Date.now()
|
||||||
serverData.refreshing = true
|
serverData.refreshing = true
|
||||||
await get_server_status(address, protocolVersion)
|
await get_server_status(address, protocolVersion)
|
||||||
.then((status) => {
|
.then((status) => {
|
||||||
|
if (serverData.lastSuccessfulRefresh && serverData.lastSuccessfulRefresh > refreshTime) {
|
||||||
|
// Don't update if there was a more recent successful refresh
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverData.lastSuccessfulRefresh = Date.now()
|
||||||
serverData.status = status
|
serverData.status = status
|
||||||
if (status.description) {
|
if (status.description) {
|
||||||
serverData.rawMotd = status.description
|
serverData.rawMotd = status.description
|
||||||
serverData.renderedMotd = autoToHTML(status.description)
|
serverData.renderedMotd = autoToHTML(status.description)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
|
||||||
console.error(`Refreshing addr: ${address}`, err)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
serverData.refreshing = false
|
serverData.refreshing = false
|
||||||
})
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(`Refreshing addr ${address}`, protocolVersion, err)
|
||||||
|
if (!protocolVersion?.legacy) {
|
||||||
|
refreshServerData(serverData, { version: 74, legacy: true }, address)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshServers(
|
export function refreshServers(
|
||||||
worlds: World[],
|
worlds: World[],
|
||||||
serverData: Record<string, ServerData>,
|
serverData: Record<string, ServerData>,
|
||||||
protocolVersion: number | null,
|
protocolVersion: ProtocolVersion | null,
|
||||||
) {
|
) {
|
||||||
const servers = worlds.filter(isServerWorld)
|
const servers = worlds.filter(isServerWorld)
|
||||||
servers.forEach((server) => {
|
servers.forEach((server) => {
|
||||||
@@ -243,10 +259,8 @@ export async function refreshServers(
|
|||||||
})
|
})
|
||||||
|
|
||||||
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
|
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
|
||||||
Promise.all(
|
Object.keys(serverData).forEach((address) =>
|
||||||
Object.keys(serverData).map((address) =>
|
refreshServerData(serverData[address], protocolVersion, address),
|
||||||
refreshServerData(serverData[address], protocolVersion, address),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -377,6 +377,12 @@
|
|||||||
"instance.worlds.hardcore": {
|
"instance.worlds.hardcore": {
|
||||||
"message": "Hardcore mode"
|
"message": "Hardcore mode"
|
||||||
},
|
},
|
||||||
|
"instance.worlds.incompatible_server": {
|
||||||
|
"message": "Server is incompatible"
|
||||||
|
},
|
||||||
|
"instance.worlds.no_contact": {
|
||||||
|
"message": "Server couldn't be contacted"
|
||||||
|
},
|
||||||
"instance.worlds.no_quick_play": {
|
"instance.worlds.no_quick_play": {
|
||||||
"message": "You can only jump straight into worlds on Minecraft 1.20+"
|
"message": "You can only jump straight into worlds on Minecraft 1.20+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ async function refreshSearch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
results.value = rawResults.result
|
results.value = rawResults.result
|
||||||
currentPage.value = Math.max(1, Math.min(pageCount.value, currentPage.value))
|
currentPage.value = 1
|
||||||
|
|
||||||
const persistentParams: LocationQuery = {}
|
const persistentParams: LocationQuery = {}
|
||||||
|
|
||||||
@@ -266,6 +266,7 @@ async function onSearchChangeToTop() {
|
|||||||
|
|
||||||
function clearSearch() {
|
function clearSearch() {
|
||||||
query.value = ''
|
query.value = ''
|
||||||
|
currentPage.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
import { get as getSettings } from '@/helpers/settings.ts'
|
import { get as getSettings } from '@/helpers/settings.ts'
|
||||||
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
|
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
|
||||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||||
import { generateSkinPreviews, map } from '@/helpers/rendering/batch-skin-renderer.ts'
|
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||||
import { handleSevereError } from '@/store/error'
|
import { handleSevereError } from '@/store/error'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||||
@@ -215,7 +215,7 @@ async function loadCurrentUser() {
|
|||||||
|
|
||||||
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
|
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
|
||||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||||
return map.get(key)
|
return skinBlobUrlMap.get(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
|
|||||||
@@ -483,7 +483,7 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
height: calc(100vh - 11rem);
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-row {
|
.button-row {
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ import {
|
|||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
|
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
|
type ProtocolVersion,
|
||||||
type SingleplayerWorld,
|
type SingleplayerWorld,
|
||||||
type World,
|
type World,
|
||||||
type ServerWorld,
|
type ServerWorld,
|
||||||
@@ -210,7 +211,9 @@ const worldPlaying = ref<World>()
|
|||||||
const worlds = ref<World[]>([])
|
const worlds = ref<World[]>([])
|
||||||
const serverData = ref<Record<string, ServerData>>({})
|
const serverData = ref<Record<string, ServerData>>({})
|
||||||
|
|
||||||
const protocolVersion = ref<number | null>(await get_profile_protocol_version(instance.value.path))
|
const protocolVersion = ref<ProtocolVersion | null>(
|
||||||
|
await get_profile_protocol_version(instance.value.path),
|
||||||
|
)
|
||||||
|
|
||||||
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
|
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
|
||||||
if (e.profile_path_id !== instance.value.path) return
|
if (e.profile_path_id !== instance.value.path) return
|
||||||
@@ -246,7 +249,7 @@ async function refreshAllWorlds() {
|
|||||||
worlds.value = await refreshWorlds(instance.value.path).finally(
|
worlds.value = await refreshWorlds(instance.value.path).finally(
|
||||||
() => (refreshingAll.value = false),
|
() => (refreshingAll.value = false),
|
||||||
)
|
)
|
||||||
await refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
||||||
|
|
||||||
const hasNoWorlds = worlds.value.length === 0
|
const hasNoWorlds = worlds.value.length === 0
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
|||||||
println!("A browser window will now open, follow the login flow there.");
|
println!("A browser window will now open, follow the login flow there.");
|
||||||
let login = minecraft_auth::begin_login().await?;
|
let login = minecraft_auth::begin_login().await?;
|
||||||
|
|
||||||
println!("Open URL {} in a browser", login.redirect_uri.as_str());
|
println!("Open URL {} in a browser", login.auth_request_uri.as_str());
|
||||||
|
|
||||||
println!("Please enter URL code: ");
|
println!("Please enter URL code: ");
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ thiserror.workspace = true
|
|||||||
daedalus.workspace = true
|
daedalus.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
either.workspace = true
|
either.workspace = true
|
||||||
|
hyper = { workspace = true, features = ["server"] }
|
||||||
|
hyper-util.workspace = true
|
||||||
|
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
urlencoding.workspace = true
|
urlencoding.workspace = true
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ fn main() {
|
|||||||
InlinedPlugin::new()
|
InlinedPlugin::new()
|
||||||
.commands(&[
|
.commands(&[
|
||||||
"offline_login",
|
"offline_login",
|
||||||
|
"elyby_login",
|
||||||
|
"elyby_auth_authenticate",
|
||||||
"login",
|
"login",
|
||||||
"remove_user",
|
"remove_user",
|
||||||
"get_default_user",
|
"get_default_user",
|
||||||
@@ -49,6 +51,8 @@ fn main() {
|
|||||||
"import",
|
"import",
|
||||||
InlinedPlugin::new()
|
InlinedPlugin::new()
|
||||||
.commands(&[
|
.commands(&[
|
||||||
|
"fetch_curseforge_profile_metadata",
|
||||||
|
"import_curseforge_profile",
|
||||||
"get_importable_instances",
|
"get_importable_instances",
|
||||||
"import_instance",
|
"import_instance",
|
||||||
"is_valid_importable_instance",
|
"is_valid_importable_instance",
|
||||||
@@ -121,7 +125,12 @@ fn main() {
|
|||||||
.plugin(
|
.plugin(
|
||||||
"mr-auth",
|
"mr-auth",
|
||||||
InlinedPlugin::new()
|
InlinedPlugin::new()
|
||||||
.commands(&["modrinth_login", "logout", "get"])
|
.commands(&[
|
||||||
|
"modrinth_login",
|
||||||
|
"logout",
|
||||||
|
"get",
|
||||||
|
"cancel_modrinth_login",
|
||||||
|
])
|
||||||
.default_permission(
|
.default_permission(
|
||||||
DefaultPermissionRule::AllowAllCommands,
|
DefaultPermissionRule::AllowAllCommands,
|
||||||
),
|
),
|
||||||
@@ -218,7 +227,9 @@ fn main() {
|
|||||||
"utils",
|
"utils",
|
||||||
InlinedPlugin::new()
|
InlinedPlugin::new()
|
||||||
.commands(&[
|
.commands(&[
|
||||||
"get_artifact",
|
"init_authlib_patching",
|
||||||
|
"apply_migration_fix",
|
||||||
|
"init_update_launcher",
|
||||||
"get_os",
|
"get_os",
|
||||||
"should_disable_mouseover",
|
"should_disable_mouseover",
|
||||||
"highlight_in_folder",
|
"highlight_in_folder",
|
||||||
|
|||||||
@@ -19,13 +19,10 @@
|
|||||||
"window-state:default",
|
"window-state:default",
|
||||||
"window-state:allow-restore-state",
|
"window-state:allow-restore-state",
|
||||||
"window-state:allow-save-window-state",
|
"window-state:allow-save-window-state",
|
||||||
|
|
||||||
{
|
{
|
||||||
"identifier": "http:default",
|
"identifier": "http:default",
|
||||||
"allow": [
|
"allow": [{ "url": "https://modrinth.com/*" }, { "url": "https://*.modrinth.com/*" }]
|
||||||
{ "url": "https://modrinth.com/*" },
|
|
||||||
{ "url": "https://*.modrinth.com/*" }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"auth:default",
|
"auth:default",
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ use crate::api::Result;
|
|||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use tauri::plugin::TauriPlugin;
|
use tauri::plugin::TauriPlugin;
|
||||||
use tauri::{Manager, Runtime, UserAttentionType};
|
use tauri::{Manager, Runtime, UserAttentionType};
|
||||||
|
use tauri_plugin_http::reqwest::Client;
|
||||||
use theseus::prelude::*;
|
use theseus::prelude::*;
|
||||||
|
|
||||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
tauri::plugin::Builder::<R>::new("auth")
|
tauri::plugin::Builder::<R>::new("auth")
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
offline_login,
|
offline_login,
|
||||||
|
elyby_login,
|
||||||
|
elyby_auth_authenticate,
|
||||||
login,
|
login,
|
||||||
remove_user,
|
remove_user,
|
||||||
get_default_user,
|
get_default_user,
|
||||||
@@ -17,14 +20,65 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ### AR • Feature
|
||||||
/// Create new offline user
|
/// Create new offline user
|
||||||
/// This is custom function from Astralium Org.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn offline_login(name: &str) -> Result<Credentials> {
|
pub async fn offline_login(name: &str) -> Result<Credentials> {
|
||||||
let credentials = minecraft_auth::offline_auth(name).await?;
|
let credentials = minecraft_auth::offline_auth(name).await?;
|
||||||
Ok(credentials)
|
Ok(credentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ### AR • Feature
|
||||||
|
/// Create new Ely.by user
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn elyby_login(
|
||||||
|
uuid: uuid::Uuid,
|
||||||
|
login: &str,
|
||||||
|
access_token: &str
|
||||||
|
) -> Result<Credentials> {
|
||||||
|
let credentials = minecraft_auth::elyby_auth(uuid, login, access_token).await?;
|
||||||
|
Ok(credentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ### AR • Feature
|
||||||
|
/// Authenticate Ely.by user
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn elyby_auth_authenticate(
|
||||||
|
login: &str,
|
||||||
|
password: &str,
|
||||||
|
client_token: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let client = Client::new();
|
||||||
|
let auth_body = serde_json::json!({
|
||||||
|
"username": login,
|
||||||
|
"password": password,
|
||||||
|
"clientToken": client_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = match client
|
||||||
|
.post("https://authserver.ely.by/auth/authenticate")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&auth_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[AR] • Failed to send request: {}", e);
|
||||||
|
return Ok("".to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = match response.text().await {
|
||||||
|
Ok(body) => body,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[AR] • Failed to read response text: {}", e);
|
||||||
|
return Ok("".to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
/// Authenticate a user with Hydra - part 1
|
/// Authenticate a user with Hydra - part 1
|
||||||
/// This begins the authentication flow quasi-synchronously, returning a URL to visit (that the user will sign in at)
|
/// This begins the authentication flow quasi-synchronously, returning a URL to visit (that the user will sign in at)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -42,7 +96,7 @@ pub async fn login<R: Runtime>(
|
|||||||
let window = tauri::WebviewWindowBuilder::new(
|
let window = tauri::WebviewWindowBuilder::new(
|
||||||
&app,
|
&app,
|
||||||
"signin",
|
"signin",
|
||||||
tauri::WebviewUrl::External(flow.redirect_uri.parse().map_err(
|
tauri::WebviewUrl::External(flow.auth_request_uri.parse().map_err(
|
||||||
|_| {
|
|_| {
|
||||||
theseus::ErrorKind::OtherError(
|
theseus::ErrorKind::OtherError(
|
||||||
"Error parsing auth redirect URL".to_string(),
|
"Error parsing auth redirect URL".to_string(),
|
||||||
@@ -86,6 +140,7 @@ pub async fn login<R: Runtime>(
|
|||||||
window.close()?;
|
window.close()?;
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
|
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
|
||||||
Ok(minecraft_auth::remove_user(user).await?)
|
Ok(minecraft_auth::remove_user(user).await?)
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use crate::api::Result;
|
use crate::api::Result;
|
||||||
use theseus::pack::import::ImportLauncherType;
|
use theseus::pack::import::ImportLauncherType;
|
||||||
|
use theseus::pack::import::curseforge_profile::{
|
||||||
|
CurseForgeProfileMetadata,
|
||||||
|
fetch_curseforge_profile_metadata as fetch_cf_metadata,
|
||||||
|
import_curseforge_profile as import_cf_profile,
|
||||||
|
};
|
||||||
|
|
||||||
use theseus::pack::import;
|
use theseus::pack::import;
|
||||||
|
|
||||||
@@ -12,6 +17,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
import_instance,
|
import_instance,
|
||||||
is_valid_importable_instance,
|
is_valid_importable_instance,
|
||||||
get_default_launcher_path,
|
get_default_launcher_path,
|
||||||
|
fetch_curseforge_profile_metadata,
|
||||||
|
import_curseforge_profile,
|
||||||
])
|
])
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
@@ -68,3 +75,24 @@ pub async fn get_default_launcher_path(
|
|||||||
) -> Result<Option<PathBuf>> {
|
) -> Result<Option<PathBuf>> {
|
||||||
Ok(import::get_default_launcher_path(launcher_type))
|
Ok(import::get_default_launcher_path(launcher_type))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch CurseForge profile metadata from profile code
|
||||||
|
/// eg: fetch_curseforge_profile_metadata("eSrNlKNo")
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn fetch_curseforge_profile_metadata(
|
||||||
|
profile_code: String,
|
||||||
|
) -> Result<CurseForgeProfileMetadata> {
|
||||||
|
Ok(fetch_cf_metadata(&profile_code).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a CurseForge profile from profile code
|
||||||
|
/// profile_path should be a blank profile for this purpose- if the function fails, it will be deleted
|
||||||
|
/// eg: import_curseforge_profile("profile-path", "eSrNlKNo")
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_curseforge_profile(
|
||||||
|
profile_path: String,
|
||||||
|
profile_code: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
import_cf_profile(&profile_code, &profile_path).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ pub mod cache;
|
|||||||
pub mod friends;
|
pub mod friends;
|
||||||
pub mod worlds;
|
pub mod worlds;
|
||||||
|
|
||||||
|
mod oauth_utils;
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
|
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
|
||||||
|
|
||||||
// // Main returnable Theseus GUI error
|
// // Main returnable Theseus GUI error
|
||||||
|
|||||||
@@ -1,79 +1,70 @@
|
|||||||
use crate::api::Result;
|
use crate::api::Result;
|
||||||
use chrono::{Duration, Utc};
|
use crate::api::TheseusSerializableError;
|
||||||
|
use crate::api::oauth_utils;
|
||||||
|
use tauri::Manager;
|
||||||
|
use tauri::Runtime;
|
||||||
use tauri::plugin::TauriPlugin;
|
use tauri::plugin::TauriPlugin;
|
||||||
use tauri::{Manager, Runtime, UserAttentionType};
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use theseus::prelude::*;
|
use theseus::prelude::*;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
||||||
tauri::plugin::Builder::new("mr-auth")
|
tauri::plugin::Builder::new("mr-auth")
|
||||||
.invoke_handler(tauri::generate_handler![modrinth_login, logout, get,])
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
modrinth_login,
|
||||||
|
logout,
|
||||||
|
get,
|
||||||
|
cancel_modrinth_login,
|
||||||
|
])
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn modrinth_login<R: Runtime>(
|
pub async fn modrinth_login<R: Runtime>(
|
||||||
app: tauri::AppHandle<R>,
|
app: tauri::AppHandle<R>,
|
||||||
) -> Result<Option<ModrinthCredentials>> {
|
) -> Result<ModrinthCredentials> {
|
||||||
let redirect_uri = mr_auth::authenticate_begin_flow();
|
let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel();
|
||||||
|
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen(
|
||||||
|
auth_code_recv_socket_tx,
|
||||||
|
));
|
||||||
|
|
||||||
let start = Utc::now();
|
let auth_code_recv_socket = auth_code_recv_socket.await.unwrap()?;
|
||||||
|
|
||||||
if let Some(window) = app.get_webview_window("modrinth-signin") {
|
let auth_request_uri = format!(
|
||||||
window.close()?;
|
"{}?launcher=true&ipver={}&port={}",
|
||||||
}
|
mr_auth::authenticate_begin_flow(),
|
||||||
|
if auth_code_recv_socket.is_ipv4() {
|
||||||
|
"4"
|
||||||
|
} else {
|
||||||
|
"6"
|
||||||
|
},
|
||||||
|
auth_code_recv_socket.port()
|
||||||
|
);
|
||||||
|
|
||||||
let window = tauri::WebviewWindowBuilder::new(
|
app.opener()
|
||||||
&app,
|
.open_url(auth_request_uri, None::<&str>)
|
||||||
"modrinth-signin",
|
.map_err(|e| {
|
||||||
tauri::WebviewUrl::External(redirect_uri.parse().map_err(|_| {
|
TheseusSerializableError::Theseus(
|
||||||
theseus::ErrorKind::OtherError(
|
theseus::ErrorKind::OtherError(format!(
|
||||||
"Error parsing auth redirect URL".to_string(),
|
"Failed to open auth request URI: {e}"
|
||||||
|
))
|
||||||
|
.into(),
|
||||||
)
|
)
|
||||||
.as_error()
|
})?;
|
||||||
})?),
|
|
||||||
)
|
|
||||||
.min_inner_size(420.0, 632.0)
|
|
||||||
.inner_size(420.0, 632.0)
|
|
||||||
.max_inner_size(420.0, 632.0)
|
|
||||||
.zoom_hotkeys_enabled(false)
|
|
||||||
.title("Sign into Modrinth")
|
|
||||||
.always_on_top(true)
|
|
||||||
.center()
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
window.request_user_attention(Some(UserAttentionType::Critical))?;
|
let Some(auth_code) = auth_code.await.unwrap()? else {
|
||||||
|
return Err(TheseusSerializableError::Theseus(
|
||||||
|
theseus::ErrorKind::OtherError("Login canceled".into()).into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
while (Utc::now() - start) < Duration::minutes(10) {
|
let credentials = mr_auth::authenticate_finish_flow(&auth_code).await?;
|
||||||
if window.title().is_err() {
|
|
||||||
// user closed window, cancelling flow
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
if window
|
if let Some(main_window) = app.get_window("main") {
|
||||||
.url()?
|
main_window.set_focus().ok();
|
||||||
.as_str()
|
|
||||||
.starts_with("https://launcher-files.modrinth.com")
|
|
||||||
{
|
|
||||||
let url = window.url()?;
|
|
||||||
|
|
||||||
let code = url.query_pairs().find(|(key, _)| key == "code");
|
|
||||||
|
|
||||||
window.close()?;
|
|
||||||
|
|
||||||
return if let Some((_, code)) = code {
|
|
||||||
let val = mr_auth::authenticate_finish_flow(&code).await?;
|
|
||||||
|
|
||||||
Ok(Some(val))
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.close()?;
|
Ok(credentials)
|
||||||
Ok(None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -85,3 +76,8 @@ pub async fn logout() -> Result<()> {
|
|||||||
pub async fn get() -> Result<Option<ModrinthCredentials>> {
|
pub async fn get() -> Result<Option<ModrinthCredentials>> {
|
||||||
Ok(theseus::mr_auth::get_credentials().await?)
|
Ok(theseus::mr_auth::get_credentials().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cancel_modrinth_login() {
|
||||||
|
oauth_utils::auth_code_reply::stop_listeners();
|
||||||
|
}
|
||||||
|
|||||||
159
apps/app/src/api/oauth_utils/auth_code_reply.rs
Normal file
159
apps/app/src/api/oauth_utils/auth_code_reply.rs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
//! A minimal OAuth 2.0 authorization code grant flow redirection/reply loopback URI HTTP
|
||||||
|
//! server implementation, compliant with [RFC 6749]'s authorization code grant flow and
|
||||||
|
//! [RFC 8252]'s best current practices for OAuth 2.0 in native apps.
|
||||||
|
//!
|
||||||
|
//! This server is needed for the step 4 of the OAuth authentication dance represented in
|
||||||
|
//! figure 1 of [RFC 8252].
|
||||||
|
//!
|
||||||
|
//! Further reading: https://www.oauth.com/oauth2-servers/oauth-native-apps/redirect-urls-for-native-apps/
|
||||||
|
//!
|
||||||
|
//! [RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749
|
||||||
|
//! [RFC 8252]: https://datatracker.ietf.org/doc/html/rfc8252
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||||
|
sync::{LazyLock, Mutex},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use hyper::body::Incoming;
|
||||||
|
use hyper_util::rt::{TokioIo, TokioTimer};
|
||||||
|
use theseus::ErrorKind;
|
||||||
|
use tokio::{
|
||||||
|
net::TcpListener,
|
||||||
|
sync::{broadcast, oneshot},
|
||||||
|
};
|
||||||
|
|
||||||
|
static SERVER_SHUTDOWN: LazyLock<broadcast::Sender<()>> =
|
||||||
|
LazyLock::new(|| broadcast::channel(1024).0);
|
||||||
|
|
||||||
|
/// Starts a temporary HTTP server to receive OAuth 2.0 authorization code grant flow redirects
|
||||||
|
/// on a loopback interface with an ephemeral port. The caller can know the bound socket address
|
||||||
|
/// by listening on the counterpart channel for `listen_socket_tx`.
|
||||||
|
///
|
||||||
|
/// If the server is stopped before receiving an authorization code, `Ok(None)` is returned.
|
||||||
|
pub async fn listen(
|
||||||
|
listen_socket_tx: oneshot::Sender<Result<SocketAddr, theseus::Error>>,
|
||||||
|
) -> Result<Option<String>, theseus::Error> {
|
||||||
|
// IPv4 is tried first for the best compatibility and performance with most systems.
|
||||||
|
// IPv6 is also tried in case IPv4 is not available. Resolving "localhost" is avoided
|
||||||
|
// to prevent failures deriving from improper name resolution setup. Any available
|
||||||
|
// ephemeral port is used to prevent conflicts with other services. This is all as per
|
||||||
|
// RFC 8252's recommendations
|
||||||
|
const ANY_LOOPBACK_SOCKET: &[SocketAddr] = &[
|
||||||
|
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
|
||||||
|
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
|
||||||
|
];
|
||||||
|
|
||||||
|
let listener = match TcpListener::bind(ANY_LOOPBACK_SOCKET).await {
|
||||||
|
Ok(listener) => {
|
||||||
|
listen_socket_tx
|
||||||
|
.send(listener.local_addr().map_err(|e| {
|
||||||
|
ErrorKind::OtherError(format!(
|
||||||
|
"Failed to get auth code reply socket address: {e}"
|
||||||
|
))
|
||||||
|
.into()
|
||||||
|
}))
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
listener
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let error_msg =
|
||||||
|
format!("Failed to bind auth code reply socket: {e}");
|
||||||
|
|
||||||
|
listen_socket_tx
|
||||||
|
.send(Err(ErrorKind::OtherError(error_msg.clone()).into()))
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
return Err(ErrorKind::OtherError(error_msg).into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut auth_code = Mutex::new(None);
|
||||||
|
let mut shutdown_notification = SERVER_SHUTDOWN.subscribe();
|
||||||
|
|
||||||
|
while auth_code.get_mut().unwrap().is_none() {
|
||||||
|
let client_socket = tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = shutdown_notification.recv() => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
conn_accept_result = listener.accept() => {
|
||||||
|
match conn_accept_result {
|
||||||
|
Ok((socket, _)) => socket,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to accept auth code reply: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = hyper::server::conn::http1::Builder::new()
|
||||||
|
.keep_alive(false)
|
||||||
|
.header_read_timeout(Duration::from_secs(5))
|
||||||
|
.timer(TokioTimer::new())
|
||||||
|
.auto_date_header(false)
|
||||||
|
.serve_connection(
|
||||||
|
TokioIo::new(client_socket),
|
||||||
|
hyper::service::service_fn(|req| handle_reply(req, &auth_code)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("Failed to handle auth code reply: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(auth_code.into_inner().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops any active OAuth 2.0 authorization code grant flow reply listening HTTP servers.
|
||||||
|
pub fn stop_listeners() {
|
||||||
|
SERVER_SHUTDOWN.send(()).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_reply(
|
||||||
|
req: hyper::Request<Incoming>,
|
||||||
|
auth_code_out: &Mutex<Option<String>>,
|
||||||
|
) -> Result<hyper::Response<String>, hyper::http::Error> {
|
||||||
|
if req.method() != hyper::Method::GET {
|
||||||
|
return hyper::Response::builder()
|
||||||
|
.status(hyper::StatusCode::METHOD_NOT_ALLOWED)
|
||||||
|
.header("Allow", "GET")
|
||||||
|
.body("".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The authorization code is guaranteed to be sent as a "code" query parameter
|
||||||
|
// in the request URI query string as per RFC 6749 § 4.1.2
|
||||||
|
let auth_code = req.uri().query().and_then(|query_string| {
|
||||||
|
query_string
|
||||||
|
.split('&')
|
||||||
|
.filter_map(|query_pair| query_pair.split_once('='))
|
||||||
|
.find_map(|(key, value)| (key == "code").then_some(value))
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = if let Some(auth_code) = auth_code {
|
||||||
|
*auth_code_out.lock().unwrap() = Some(auth_code.to_string());
|
||||||
|
|
||||||
|
hyper::Response::builder()
|
||||||
|
.status(hyper::StatusCode::OK)
|
||||||
|
.header("Content-Type", "text/html;charset=utf-8")
|
||||||
|
.body(
|
||||||
|
include_str!("auth_code_reply/page.html")
|
||||||
|
.replace("{{title}}", "Success")
|
||||||
|
.replace("{{message}}", "You have successfully signed in! You can close this page now."),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
hyper::Response::builder()
|
||||||
|
.status(hyper::StatusCode::BAD_REQUEST)
|
||||||
|
.header("Content-Type", "text/html;charset=utf-8")
|
||||||
|
.body(
|
||||||
|
include_str!("auth_code_reply/page.html")
|
||||||
|
.replace("{{title}}", "Error")
|
||||||
|
.replace("{{message}}", "Authorization code not found. Please try signing in again."),
|
||||||
|
)
|
||||||
|
}?;
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
1
apps/app/src/api/oauth_utils/auth_code_reply/page.html
Normal file
1
apps/app/src/api/oauth_utils/auth_code_reply/page.html
Normal file
File diff suppressed because one or more lines are too long
3
apps/app/src/api/oauth_utils/mod.rs
Normal file
3
apps/app/src/api/oauth_utils/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
//! Assorted utilities for OAuth 2.0 authorization flows.
|
||||||
|
|
||||||
|
pub mod auth_code_reply;
|
||||||
@@ -10,12 +10,15 @@ use crate::api::{Result, TheseusSerializableError};
|
|||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use theseus::prelude::canonicalize;
|
use theseus::prelude::canonicalize;
|
||||||
|
use theseus::util::utils;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||||
tauri::plugin::Builder::new("utils")
|
tauri::plugin::Builder::new("utils")
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_artifact,
|
init_authlib_patching,
|
||||||
|
apply_migration_fix,
|
||||||
|
init_update_launcher,
|
||||||
get_os,
|
get_os,
|
||||||
should_disable_mouseover,
|
should_disable_mouseover,
|
||||||
highlight_in_folder,
|
highlight_in_folder,
|
||||||
@@ -27,9 +30,39 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [AR] Feature. Ely.by
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_artifact(downloadurl: &str, filename: &str, ostype: &str, autoupdatesupported: bool) -> Result<()> {
|
pub async fn init_authlib_patching(
|
||||||
theseus::download::init_download(downloadurl, filename, ostype, autoupdatesupported).await;
|
minecraft_version: &str,
|
||||||
|
is_mojang: bool,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let result =
|
||||||
|
utils::init_authlib_patching(minecraft_version, is_mojang).await?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [AR] Migration. Patch
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn apply_migration_fix(eol: &str) -> Result<bool> {
|
||||||
|
let result = utils::apply_migration_fix(eol).await?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [AR] Feature. Updater
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn init_update_launcher(
|
||||||
|
download_url: &str,
|
||||||
|
filename: &str,
|
||||||
|
os_type: &str,
|
||||||
|
auto_update_supported: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let _ = utils::init_update_launcher(
|
||||||
|
download_url,
|
||||||
|
filename,
|
||||||
|
os_type,
|
||||||
|
auto_update_supported,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use tauri::{AppHandle, Manager, Runtime};
|
|||||||
use theseus::prelude::ProcessMetadata;
|
use theseus::prelude::ProcessMetadata;
|
||||||
use theseus::profile::{QuickPlayType, get_full_path};
|
use theseus::profile::{QuickPlayType, get_full_path};
|
||||||
use theseus::worlds::{
|
use theseus::worlds::{
|
||||||
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType,
|
DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
|
||||||
WorldWithProfile,
|
WorldType, WorldWithProfile,
|
||||||
};
|
};
|
||||||
use theseus::{profile, worlds};
|
use theseus::{profile, worlds};
|
||||||
|
|
||||||
@@ -183,14 +183,16 @@ pub async fn remove_server_from_profile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_profile_protocol_version(path: &str) -> Result<Option<i32>> {
|
pub async fn get_profile_protocol_version(
|
||||||
|
path: &str,
|
||||||
|
) -> Result<Option<ProtocolVersion>> {
|
||||||
Ok(worlds::get_profile_protocol_version(path).await?)
|
Ok(worlds::get_profile_protocol_version(path).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_server_status(
|
pub async fn get_server_status(
|
||||||
address: &str,
|
address: &str,
|
||||||
protocol_version: Option<i32>,
|
protocol_version: Option<ProtocolVersion>,
|
||||||
) -> Result<ServerStatus> {
|
) -> Result<ServerStatus> {
|
||||||
Ok(worlds::get_server_status(address, protocol_version).await?)
|
Ok(worlds::get_server_status(address, protocol_version).await?)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
|
|||||||
|
|
||||||
// let update_fut = updater.check();
|
// let update_fut = updater.check();
|
||||||
|
|
||||||
// tracing::info!("Initializing app state...");
|
|
||||||
State::init().await?;
|
|
||||||
|
|
||||||
// let check_bar = theseus::init_loading(
|
// let check_bar = theseus::init_loading(
|
||||||
// theseus::LoadingBarType::CheckingForUpdates,
|
// theseus::LoadingBarType::CheckingForUpdates,
|
||||||
// 1.0,
|
// 1.0,
|
||||||
@@ -87,7 +84,7 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
|
|||||||
// #[cfg(not(feature = "updater"))]
|
// #[cfg(not(feature = "updater"))]
|
||||||
// {
|
// {
|
||||||
// }
|
// }
|
||||||
|
tracing::info!("Initializing app state...");
|
||||||
State::init().await?;
|
State::init().await?;
|
||||||
tracing::info!("AstralRinth state successfully initialized.");
|
tracing::info!("AstralRinth state successfully initialized.");
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
@@ -160,14 +157,14 @@ fn main() {
|
|||||||
*/
|
*/
|
||||||
let _log_guard = theseus::start_logger();
|
let _log_guard = theseus::start_logger();
|
||||||
|
|
||||||
tracing::info!("Initialized tracing subscriber. Loading Modrinth App!");
|
tracing::info!("Initialized tracing subscriber. Loading AstralRinth App!");
|
||||||
|
|
||||||
let mut builder = tauri::Builder::default();
|
let mut builder = tauri::Builder::default();
|
||||||
|
|
||||||
#[cfg(feature = "updater")]
|
// #[cfg(feature = "updater")]
|
||||||
{
|
// {
|
||||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
// builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||||
}
|
// }
|
||||||
|
|
||||||
builder = builder
|
builder = builder
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"productName": "AstralRinth App",
|
"productName": "AstralRinth App",
|
||||||
"version": "0.10.3",
|
"version": "0.10.305",
|
||||||
"mainBinaryName": "AstralRinth App",
|
"mainBinaryName": "AstralRinth App",
|
||||||
"identifier": "AstralRinthApp",
|
"identifier": "AstralRinthApp",
|
||||||
"plugins": {
|
"plugins": {
|
||||||
@@ -63,6 +63,7 @@
|
|||||||
"height": 800,
|
"height": 800,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"title": "AstralRinth",
|
"title": "AstralRinth",
|
||||||
|
"label": "main",
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"minHeight": 700,
|
"minHeight": 700,
|
||||||
"minWidth": 1100,
|
"minWidth": 1100,
|
||||||
@@ -86,7 +87,7 @@
|
|||||||
"capabilities": ["core", "plugins"],
|
"capabilities": ["core", "plugins"],
|
||||||
"csp": {
|
"csp": {
|
||||||
"default-src": "'self' customprotocol: asset:",
|
"default-src": "'self' customprotocol: asset:",
|
||||||
"connect-src": "ipc: https://git.astralium.su http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:",
|
"connect-src": "ipc: https://git.astralium.su https://authserver.ely.by http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:",
|
||||||
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
||||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
||||||
"style-src": "'unsafe-inline' 'self'",
|
"style-src": "'unsafe-inline' 'self'",
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM rust:1.88.0 AS build
|
FROM rust:1.88.0 AS build
|
||||||
ENV PKG_CONFIG_ALLOW_CROSS=1
|
|
||||||
|
|
||||||
WORKDIR /usr/src/daedalus
|
WORKDIR /usr/src/daedalus
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN cargo build --release --package daedalus_client
|
RUN --mount=type=cache,target=/usr/src/daedalus/target \
|
||||||
|
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||||
|
--mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
|
cargo build --release --package daedalus_client
|
||||||
|
|
||||||
|
FROM build AS artifacts
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/usr/src/daedalus/target \
|
||||||
|
mkdir /daedalus \
|
||||||
|
&& cp /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
||||||
&& apt-get clean \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN update-ca-certificates
|
COPY --from=artifacts /daedalus /daedalus
|
||||||
|
|
||||||
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
|
||||||
WORKDIR /daedalus_client
|
WORKDIR /daedalus_client
|
||||||
|
CMD ["/daedalus/daedalus_client"]
|
||||||
CMD /daedalus/daedalus_client
|
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ From there, you can create the database and perform all database migrations with
|
|||||||
sqlx database setup
|
sqlx database setup
|
||||||
```
|
```
|
||||||
|
|
||||||
Finally, if on Linux, you will need the OpenSSL library. On Debian-based systems, this involves the `pkg-config` and `libssl-dev` packages.
|
|
||||||
|
|
||||||
To enable labrinth to create a project, you need to add two things.
|
To enable labrinth to create a project, you need to add two things.
|
||||||
|
|
||||||
1. An entry in the `loaders` table.
|
1. An entry in the `loaders` table.
|
||||||
|
|||||||
@@ -38,9 +38,10 @@
|
|||||||
"@intercom/messenger-js-sdk": "^0.0.14",
|
"@intercom/messenger-js-sdk": "^0.0.14",
|
||||||
"@ltd/j-toml": "^1.38.0",
|
"@ltd/j-toml": "^1.38.0",
|
||||||
"@modrinth/assets": "workspace:*",
|
"@modrinth/assets": "workspace:*",
|
||||||
|
"@modrinth/blog": "workspace:*",
|
||||||
|
"@modrinth/moderation": "workspace:*",
|
||||||
"@modrinth/ui": "workspace:*",
|
"@modrinth/ui": "workspace:*",
|
||||||
"@modrinth/utils": "workspace:*",
|
"@modrinth/utils": "workspace:*",
|
||||||
"@modrinth/blog": "workspace:*",
|
|
||||||
"@pinia/nuxt": "^0.5.1",
|
"@pinia/nuxt": "^0.5.1",
|
||||||
"@types/three": "^0.172.0",
|
"@types/three": "^0.172.0",
|
||||||
"@vintl/vintl": "^4.4.1",
|
"@vintl/vintl": "^4.4.1",
|
||||||
@@ -58,9 +59,12 @@
|
|||||||
"markdown-it": "14.1.0",
|
"markdown-it": "14.1.0",
|
||||||
"pathe": "^1.1.2",
|
"pathe": "^1.1.2",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
|
"pinia-plugin-persistedstate": "^4.4.1",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
"qrcode.vue": "^3.4.0",
|
"qrcode.vue": "^3.4.0",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"three": "^0.172.0",
|
"three": "^0.172.0",
|
||||||
|
"vue-confetti-explosion": "^1.0.2",
|
||||||
"vue-multiselect": "3.0.0-alpha.2",
|
"vue-multiselect": "3.0.0-alpha.2",
|
||||||
"vue-typed-virtual-list": "^1.0.10",
|
"vue-typed-virtual-list": "^1.0.10",
|
||||||
"vue3-ace-editor": "^2.2.4",
|
"vue3-ace-editor": "^2.2.4",
|
||||||
|
|||||||
@@ -197,13 +197,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
> :where(
|
> :where(
|
||||||
input + *,
|
input + *,
|
||||||
.input-group + *,
|
.input-group + *,
|
||||||
.textarea-wrapper + *,
|
.textarea-wrapper + *,
|
||||||
.chips + *,
|
.chips + *,
|
||||||
.resizable-textarea-wrapper + *,
|
.resizable-textarea-wrapper + *,
|
||||||
.input-div + *
|
.input-div + *
|
||||||
) {
|
) {
|
||||||
&:not(:empty) {
|
&:not(:empty) {
|
||||||
margin-block-start: var(--spacing-card-md);
|
margin-block-start: var(--spacing-card-md);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,10 +115,12 @@ html {
|
|||||||
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15);
|
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15);
|
||||||
|
|
||||||
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
|
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
|
||||||
--shadow-raised: 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
|
--shadow-raised:
|
||||||
|
0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
|
||||||
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
|
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
|
||||||
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
|
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
|
||||||
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
--shadow-floating:
|
||||||
|
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||||
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
|
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
|
||||||
|
|
||||||
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
|
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
|
||||||
@@ -150,8 +152,8 @@ html {
|
|||||||
rgba(255, 255, 255, 0.35) 0%,
|
rgba(255, 255, 255, 0.35) 0%,
|
||||||
rgba(255, 255, 255, 0.2695) 100%
|
rgba(255, 255, 255, 0.2695) 100%
|
||||||
);
|
);
|
||||||
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16),
|
--landing-blob-shadow:
|
||||||
inset 2px 2px 64px rgba(255, 255, 255, 0.45);
|
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(255, 255, 255, 0.45);
|
||||||
|
|
||||||
--landing-card-bg: rgba(255, 255, 255, 0.8);
|
--landing-card-bg: rgba(255, 255, 255, 0.8);
|
||||||
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
||||||
@@ -251,13 +253,15 @@ html {
|
|||||||
|
|
||||||
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
|
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
|
||||||
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
|
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
|
||||||
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
--shadow-floating:
|
||||||
|
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||||
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
|
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
|
||||||
|
|
||||||
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
|
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
|
||||||
|
|
||||||
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp");
|
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp");
|
||||||
--landing-maze-gradient-bg: linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
|
--landing-maze-gradient-bg:
|
||||||
|
linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
|
||||||
url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
|
url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
|
||||||
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
|
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
|
||||||
|
|
||||||
@@ -284,7 +288,8 @@ html {
|
|||||||
rgba(44, 48, 79, 0.35) 0%,
|
rgba(44, 48, 79, 0.35) 0%,
|
||||||
rgba(32, 35, 50, 0.2695) 100%
|
rgba(32, 35, 50, 0.2695) 100%
|
||||||
);
|
);
|
||||||
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
|
--landing-blob-shadow:
|
||||||
|
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
|
||||||
|
|
||||||
--landing-card-bg: rgba(59, 63, 85, 0.15);
|
--landing-card-bg: rgba(59, 63, 85, 0.15);
|
||||||
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
||||||
@@ -360,8 +365,9 @@ body {
|
|||||||
// Defaults
|
// Defaults
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
|
--font-standard:
|
||||||
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
|
||||||
|
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||||
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
|
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
|
||||||
font-family: var(--font-standard);
|
font-family: var(--font-standard);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ButtonStyled } from "@modrinth/ui";
|
import { ButtonStyled } from "@modrinth/ui";
|
||||||
import { MailIcon, CheckIcon } from "@modrinth/assets";
|
import { MailIcon, CheckIcon } from "@modrinth/assets";
|
||||||
import { ref, watchEffect } from "vue";
|
import { ref } from "vue";
|
||||||
import { useBaseFetch } from "~/composables/fetch.js";
|
import { useBaseFetch } from "~/composables/fetch.js";
|
||||||
|
|
||||||
const auth = await useAuth();
|
const auth = await useAuth();
|
||||||
const showSubscriptionConfirmation = ref(false);
|
const showSubscriptionConfirmation = ref(false);
|
||||||
const subscribed = ref(false);
|
const showSubscribeButton = useAsyncData(
|
||||||
|
async () => {
|
||||||
async function checkSubscribed() {
|
if (auth.value?.user) {
|
||||||
if (auth.value?.user) {
|
try {
|
||||||
try {
|
const { subscribed } = await useBaseFetch("auth/email/subscribe", {
|
||||||
const { data } = await useBaseFetch("auth/email/subscribe", {
|
method: "GET",
|
||||||
method: "GET",
|
});
|
||||||
});
|
return !subscribed;
|
||||||
subscribed.value = data?.subscribed || false;
|
} catch {
|
||||||
} catch {
|
return true;
|
||||||
subscribed.value = false;
|
}
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
{ watch: [auth], server: false },
|
||||||
|
);
|
||||||
watchEffect(() => {
|
|
||||||
checkSubscribed();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function subscribe() {
|
async function subscribe() {
|
||||||
try {
|
try {
|
||||||
@@ -35,14 +34,19 @@ async function subscribe() {
|
|||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showSubscriptionConfirmation.value = false;
|
showSubscriptionConfirmation.value = false;
|
||||||
subscribed.value = true;
|
showSubscribeButton.status.value = "success";
|
||||||
|
showSubscribeButton.data.value = false;
|
||||||
}, 2500);
|
}, 2500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ButtonStyled v-if="auth?.user && !subscribed" color="brand" type="outlined">
|
<ButtonStyled
|
||||||
|
v-if="showSubscribeButton.status.value === 'success' && showSubscribeButton.data.value"
|
||||||
|
color="brand"
|
||||||
|
type="outlined"
|
||||||
|
>
|
||||||
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
|
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
|
||||||
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
|
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
|
||||||
<template v-else> <CheckIcon /> Subscribed! </template>
|
<template v-else> <CheckIcon /> Subscribed! </template>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="vue-notification-group experimental-styles-within"
|
class="vue-notification-group experimental-styles-within"
|
||||||
:class="{ 'intercom-present': isIntercomPresent }"
|
:class="{
|
||||||
|
'intercom-present': isIntercomPresent,
|
||||||
|
rightwards: moveNotificationsRight,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<transition-group name="notifs">
|
<transition-group name="notifs">
|
||||||
<div
|
<div
|
||||||
@@ -82,6 +85,7 @@ import {
|
|||||||
CopyIcon,
|
CopyIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
const notifications = useNotifications();
|
const notifications = useNotifications();
|
||||||
|
const { isVisible: moveNotificationsRight } = useNotificationRightwards();
|
||||||
|
|
||||||
const isIntercomPresent = ref(false);
|
const isIntercomPresent = ref(false);
|
||||||
|
|
||||||
@@ -160,6 +164,15 @@ function copyToClipboard(notif) {
|
|||||||
bottom: 5rem;
|
bottom: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.rightwards {
|
||||||
|
right: unset !important;
|
||||||
|
left: 1.5rem;
|
||||||
|
|
||||||
|
@media screen and (max-width: 500px) {
|
||||||
|
left: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.vue-notification-wrapper {
|
.vue-notification-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<template>
|
||||||
|
<div class="universal-card">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<Avatar :src="report.project.icon_url" size="3rem" class="flex-shrink-0" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="truncate text-lg font-semibold">{{ report.project.title }}</h3>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
|
||||||
|
<nuxt-link
|
||||||
|
v-if="report.target"
|
||||||
|
:to="`/${report.target.type}/${report.target.slug}`"
|
||||||
|
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="report.target.avatar_url"
|
||||||
|
:circle="report.target.type === 'user'"
|
||||||
|
size="1rem"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="truncate">
|
||||||
|
<OrganizationIcon
|
||||||
|
v-if="report.target.type === 'organization'"
|
||||||
|
class="align-middle"
|
||||||
|
/>
|
||||||
|
{{ report.target.name }}
|
||||||
|
</span>
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
|
||||||
|
>
|
||||||
|
Score: {{ report.priority_score }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold"
|
||||||
|
:class="{
|
||||||
|
'text-brand': report.status === 'approved',
|
||||||
|
'text-red': report.status === 'rejected',
|
||||||
|
'text-secondary': report.status === 'pending',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ report.status.charAt(0).toUpperCase() + report.status.slice(1) }}
|
||||||
|
</span>
|
||||||
|
<span class="max-w-[200px] truncate font-mono text-xs sm:max-w-none">
|
||||||
|
{{
|
||||||
|
report.version.files.find((file) => file.primary)?.filename ||
|
||||||
|
"Unknown primary file"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-2 flex flex-col items-stretch gap-2 sm:mt-0 sm:flex-row sm:items-center sm:gap-2"
|
||||||
|
>
|
||||||
|
<span class="hidden whitespace-nowrap text-sm text-secondary sm:block">
|
||||||
|
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ButtonStyled class="flex-1 sm:flex-none">
|
||||||
|
<button
|
||||||
|
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
|
||||||
|
:disabled="!isPending"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled class="flex-1 sm:flex-none">
|
||||||
|
<button
|
||||||
|
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
|
||||||
|
:disabled="!isPending"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center gap-2 sm:justify-start">
|
||||||
|
<ButtonStyled circular>
|
||||||
|
<nuxt-link :to="versionUrl">
|
||||||
|
<EyeIcon />
|
||||||
|
</nuxt-link>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled circular>
|
||||||
|
<OverflowMenu :options="quickActions">
|
||||||
|
<template #default>
|
||||||
|
<EllipsisVerticalIcon />
|
||||||
|
</template>
|
||||||
|
<template #copy-id>
|
||||||
|
<ClipboardCopyIcon />
|
||||||
|
<span class="hidden sm:inline">Copy ID</span>
|
||||||
|
</template>
|
||||||
|
<template #copy-link>
|
||||||
|
<LinkIcon />
|
||||||
|
<span class="hidden sm:inline">Copy link</span>
|
||||||
|
</template>
|
||||||
|
</OverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-secondary sm:hidden">
|
||||||
|
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
useRelativeTime,
|
||||||
|
OverflowMenu,
|
||||||
|
type OverflowMenuOption,
|
||||||
|
ButtonStyled,
|
||||||
|
} from "@modrinth/ui";
|
||||||
|
import {
|
||||||
|
EllipsisVerticalIcon,
|
||||||
|
OrganizationIcon,
|
||||||
|
EyeIcon,
|
||||||
|
ClipboardCopyIcon,
|
||||||
|
LinkIcon,
|
||||||
|
} from "@modrinth/assets";
|
||||||
|
import type { ExtendedDelphiReport } from "@modrinth/moderation";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
report: ExtendedDelphiReport;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const formatRelativeTime = useRelativeTime();
|
||||||
|
const isPending = computed(() => props.report.status === "pending");
|
||||||
|
|
||||||
|
const quickActions: OverflowMenuOption[] = [
|
||||||
|
{
|
||||||
|
id: "copy-link",
|
||||||
|
action: () => {
|
||||||
|
const base = window.location.origin;
|
||||||
|
const reviewUrl = `${base}/moderation/tech-reviews?q=${props.report.version.id}`;
|
||||||
|
navigator.clipboard.writeText(reviewUrl).then(() => {
|
||||||
|
addNotification({
|
||||||
|
type: "success",
|
||||||
|
title: "Tech review link copied",
|
||||||
|
text: "The link to this tech review has been copied to your clipboard.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "copy-id",
|
||||||
|
action: () => {
|
||||||
|
navigator.clipboard.writeText(props.report.version.id).then(() => {
|
||||||
|
addNotification({
|
||||||
|
type: "success",
|
||||||
|
title: "Version ID copied",
|
||||||
|
text: "The ID of this version has been copied to your clipboard.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const versionUrl = computed(() => {
|
||||||
|
return `/${props.report.project.project_type}/${props.report.project.slug}/versions/${props.report.version.id}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="universal-card flex min-h-[6rem] flex-col justify-between gap-3 rounded-lg p-4 sm:h-24 sm:flex-row sm:items-center sm:gap-0"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<div class="flex-shrink-0 rounded-lg">
|
||||||
|
<Avatar size="48px" :src="queueEntry.project.icon_url" />
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<h3 class="truncate text-lg font-semibold">
|
||||||
|
{{ queueEntry.project.name }}
|
||||||
|
</h3>
|
||||||
|
<nuxt-link
|
||||||
|
v-if="queueEntry.owner"
|
||||||
|
target="_blank"
|
||||||
|
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
|
||||||
|
:to="`/user/${queueEntry.owner.user.username}`"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="queueEntry.owner.user.avatar_url"
|
||||||
|
circle
|
||||||
|
size="16px"
|
||||||
|
class="inline-block flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ queueEntry.owner.user.username }}</span>
|
||||||
|
</nuxt-link>
|
||||||
|
<nuxt-link
|
||||||
|
v-else-if="queueEntry.org"
|
||||||
|
target="_blank"
|
||||||
|
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
|
||||||
|
:to="`/organization/${queueEntry.org.slug}`"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="queueEntry.org.icon_url"
|
||||||
|
circle
|
||||||
|
size="16px"
|
||||||
|
class="inline-block flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ queueEntry.org.name }}</span>
|
||||||
|
</nuxt-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-1">
|
||||||
|
<span class="flex items-center gap-1 whitespace-nowrap text-sm">
|
||||||
|
<BoxIcon
|
||||||
|
v-if="queueEntry.project.project_type === 'mod'"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<PaintbrushIcon
|
||||||
|
v-else-if="queueEntry.project.project_type === 'resourcepack'"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<BracesIcon
|
||||||
|
v-else-if="queueEntry.project.project_type === 'datapack'"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<PackageOpenIcon
|
||||||
|
v-else-if="queueEntry.project.project_type === 'modpack'"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<GlassesIcon
|
||||||
|
v-else-if="queueEntry.project.project_type === 'shader'"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<PlugIcon
|
||||||
|
v-else-if="queueEntry.project.project_type === 'plugin'"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="hidden sm:inline">{{
|
||||||
|
props.queueEntry.project.project_types.map(formatProjectType).join(", ")
|
||||||
|
}}</span>
|
||||||
|
<span class="sm:hidden">{{
|
||||||
|
formatProjectType(props.queueEntry.project.project_type ?? "project").substring(0, 3)
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="hidden text-sm sm:inline">•</span>
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-2 text-sm">
|
||||||
|
Requesting
|
||||||
|
<Badge
|
||||||
|
v-if="props.queueEntry.project.requested_status"
|
||||||
|
:type="props.queueEntry.project.requested_status"
|
||||||
|
class="status"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="hidden text-sm sm:inline">•</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-tooltip="`Since ${queuedDate.toLocaleString()}`"
|
||||||
|
class="truncate text-sm"
|
||||||
|
:class="{
|
||||||
|
'text-red': daysInQueue > 4,
|
||||||
|
'text-orange': daysInQueue > 2,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="hidden sm:inline">{{ getSubmittedTime(queueEntry) }}</span>
|
||||||
|
<span class="sm:hidden">{{
|
||||||
|
getSubmittedTime(queueEntry).replace("Submitted ", "")
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-2 sm:justify-start">
|
||||||
|
<ButtonStyled circular>
|
||||||
|
<NuxtLink target="_blank" :to="`/project/${queueEntry.project.slug}`">
|
||||||
|
<EyeIcon class="size-4" />
|
||||||
|
</NuxtLink>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled circular color="orange" @click="openProjectForReview">
|
||||||
|
<button>
|
||||||
|
<ScaleIcon class="size-4" />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import {
|
||||||
|
EyeIcon,
|
||||||
|
PaintbrushIcon,
|
||||||
|
ScaleIcon,
|
||||||
|
BoxIcon,
|
||||||
|
GlassesIcon,
|
||||||
|
PlugIcon,
|
||||||
|
PackageOpenIcon,
|
||||||
|
BracesIcon,
|
||||||
|
} from "@modrinth/assets";
|
||||||
|
import { useRelativeTime, Avatar, ButtonStyled, Badge } from "@modrinth/ui";
|
||||||
|
import {
|
||||||
|
formatProjectType,
|
||||||
|
type Organization,
|
||||||
|
type Project,
|
||||||
|
type TeamMember,
|
||||||
|
} from "@modrinth/utils";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useModerationStore } from "~/store/moderation.ts";
|
||||||
|
import type { ModerationProject } from "~/helpers/moderation";
|
||||||
|
|
||||||
|
const formatRelativeTime = useRelativeTime();
|
||||||
|
const moderationStore = useModerationStore();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
queueEntry: ModerationProject;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function getDaysQueued(date: Date): number {
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
const queuedDate = computed(() => {
|
||||||
|
return dayjs(
|
||||||
|
props.queueEntry.project.queued ||
|
||||||
|
props.queueEntry.project.created ||
|
||||||
|
props.queueEntry.project.updated,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const daysInQueue = computed(() => {
|
||||||
|
return getDaysQueued(queuedDate.value.toDate());
|
||||||
|
});
|
||||||
|
|
||||||
|
function openProjectForReview() {
|
||||||
|
moderationStore.setSingleProject(props.queueEntry.project.id);
|
||||||
|
navigateTo({
|
||||||
|
name: "type-id",
|
||||||
|
params: {
|
||||||
|
type: "project",
|
||||||
|
id: props.queueEntry.project.id,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
showChecklist: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubmittedTime(project: any): string {
|
||||||
|
const date =
|
||||||
|
props.queueEntry.project.queued ||
|
||||||
|
props.queueEntry.project.created ||
|
||||||
|
props.queueEntry.project.updated;
|
||||||
|
if (!date) return "Unknown";
|
||||||
|
|
||||||
|
try {
|
||||||
|
return `Submitted ${formatRelativeTime(dayjs(date).toISOString())}`;
|
||||||
|
} catch {
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<template>
|
||||||
|
<div class="universal-card">
|
||||||
|
<div
|
||||||
|
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
|
||||||
|
>
|
||||||
|
<span class="text-md flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
Reported for
|
||||||
|
<span class="whitespace-nowrap rounded-full align-middle font-semibold text-contrast">
|
||||||
|
{{ formattedReportType }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="hidden sm:inline">By</span>
|
||||||
|
<span class="sm:hidden">Reporter:</span>
|
||||||
|
<nuxt-link
|
||||||
|
:to="`/user/${report.reporter_user.username}`"
|
||||||
|
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="report.reporter_user.avatar_url"
|
||||||
|
circle
|
||||||
|
size="1.75rem"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ report.reporter_user.username }}</span>
|
||||||
|
</nuxt-link>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex flex-row items-center gap-2 self-end sm:self-auto">
|
||||||
|
<span class="text-md whitespace-nowrap text-secondary">{{
|
||||||
|
formatRelativeTime(report.created)
|
||||||
|
}}</span>
|
||||||
|
<ButtonStyled v-if="visibleQuickReplies.length > 0" circular>
|
||||||
|
<OverflowMenu :options="visibleQuickReplies">
|
||||||
|
<span class="hidden sm:inline">Quick Reply</span>
|
||||||
|
<span class="sr-only sm:hidden">Quick Reply</span>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</OverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled circular>
|
||||||
|
<OverflowMenu :options="quickActions">
|
||||||
|
<template #default>
|
||||||
|
<EllipsisVerticalIcon />
|
||||||
|
</template>
|
||||||
|
<template #copy-id>
|
||||||
|
<ClipboardCopyIcon />
|
||||||
|
<span class="hidden sm:inline">Copy ID</span>
|
||||||
|
</template>
|
||||||
|
<template #copy-link>
|
||||||
|
<LinkIcon />
|
||||||
|
<span class="hidden sm:inline">Copy link</span>
|
||||||
|
</template>
|
||||||
|
</OverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4 rounded-xl border-solid text-divider" />
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<Avatar
|
||||||
|
:src="reportItemAvatarUrl"
|
||||||
|
:circle="report.item_type === 'user'"
|
||||||
|
size="3rem"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-lg font-semibold">{{ reportItemTitle }}</span>
|
||||||
|
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
|
||||||
|
<nuxt-link
|
||||||
|
v-if="report.target && report.item_type != 'user'"
|
||||||
|
:to="`/${report.target.type}/${report.target.slug}`"
|
||||||
|
class="inline-flex flex-row items-center gap-1 truncate transition-colors duration-100 ease-in-out hover:text-brand"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="report.target?.avatar_url"
|
||||||
|
:circle="report.target.type === 'user'"
|
||||||
|
size="1rem"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="truncate">
|
||||||
|
<OrganizationIcon
|
||||||
|
v-if="report.target.type === 'organization'"
|
||||||
|
class="align-middle"
|
||||||
|
/>
|
||||||
|
{{ report.target.name || "Unknown User" }}
|
||||||
|
</span>
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
|
||||||
|
>
|
||||||
|
{{ formattedItemType }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="report.item_type === 'version' && report.version"
|
||||||
|
class="max-w-[200px] truncate font-mono text-xs sm:max-w-none"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
report.version.files.find((file) => file.primary)?.filename || "Unknown Version"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end sm:justify-start">
|
||||||
|
<ButtonStyled circular>
|
||||||
|
<nuxt-link :to="reportItemUrl">
|
||||||
|
<EyeIcon />
|
||||||
|
</nuxt-link>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CollapsibleRegion class="my-4" ref="collapsibleRegion">
|
||||||
|
<ReportThread
|
||||||
|
v-if="report.thread"
|
||||||
|
ref="reportThread"
|
||||||
|
class="mb-16 sm:mb-0"
|
||||||
|
:thread="report.thread"
|
||||||
|
:report="report"
|
||||||
|
:reporter="report.reporter_user"
|
||||||
|
@update-thread="updateThread"
|
||||||
|
/>
|
||||||
|
</CollapsibleRegion>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
useRelativeTime,
|
||||||
|
OverflowMenu,
|
||||||
|
type OverflowMenuOption,
|
||||||
|
CollapsibleRegion,
|
||||||
|
ButtonStyled,
|
||||||
|
} from "@modrinth/ui";
|
||||||
|
import {
|
||||||
|
EllipsisVerticalIcon,
|
||||||
|
OrganizationIcon,
|
||||||
|
EyeIcon,
|
||||||
|
ClipboardCopyIcon,
|
||||||
|
LinkIcon,
|
||||||
|
} from "@modrinth/assets";
|
||||||
|
import {
|
||||||
|
type ExtendedReport,
|
||||||
|
reportQuickReplies,
|
||||||
|
type ReportQuickReply,
|
||||||
|
} from "@modrinth/moderation";
|
||||||
|
import ChevronDownIcon from "../servers/icons/ChevronDownIcon.vue";
|
||||||
|
import ReportThread from "../thread/ReportThread.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
report: ExtendedReport;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const reportThread = ref<InstanceType<typeof ReportThread> | null>(null);
|
||||||
|
const collapsibleRegion = ref<InstanceType<typeof CollapsibleRegion> | null>(null);
|
||||||
|
|
||||||
|
const formatRelativeTime = useRelativeTime();
|
||||||
|
|
||||||
|
function updateThread(newThread: any) {
|
||||||
|
if (props.report.thread) {
|
||||||
|
Object.assign(props.report.thread, newThread);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quickActions: OverflowMenuOption[] = [
|
||||||
|
{
|
||||||
|
id: "copy-link",
|
||||||
|
action: () => {
|
||||||
|
const base = window.location.origin;
|
||||||
|
const reportUrl = `${base}/moderation/reports/${props.report.id}`;
|
||||||
|
navigator.clipboard.writeText(reportUrl).then(() => {
|
||||||
|
addNotification({
|
||||||
|
type: "success",
|
||||||
|
title: "Report link copied",
|
||||||
|
text: "The link to this report has been copied to your clipboard.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "copy-id",
|
||||||
|
action: () => {
|
||||||
|
navigator.clipboard.writeText(props.report.id).then(() => {
|
||||||
|
addNotification({
|
||||||
|
type: "success",
|
||||||
|
title: "Report ID copied",
|
||||||
|
text: "The ID of this report has been copied to your clipboard.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
|
||||||
|
return reportQuickReplies
|
||||||
|
.filter((reply) => {
|
||||||
|
if (reply.shouldShow === undefined) return true;
|
||||||
|
if (typeof reply.shouldShow === "function") {
|
||||||
|
return reply.shouldShow(props.report);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.shouldShow;
|
||||||
|
})
|
||||||
|
.map(
|
||||||
|
(reply) =>
|
||||||
|
({
|
||||||
|
id: reply.label,
|
||||||
|
action: () => handleQuickReply(reply),
|
||||||
|
}) as OverflowMenuOption,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleQuickReply(reply: ReportQuickReply) {
|
||||||
|
const message =
|
||||||
|
typeof reply.message === "function" ? await reply.message(props.report) : reply.message;
|
||||||
|
|
||||||
|
collapsibleRegion.value?.setCollapsed(false);
|
||||||
|
await nextTick();
|
||||||
|
reportThread.value?.setReplyContent(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportItemAvatarUrl = computed(() => {
|
||||||
|
switch (props.report.item_type) {
|
||||||
|
case "project":
|
||||||
|
case "version":
|
||||||
|
return props.report.project?.icon_url || "";
|
||||||
|
case "user":
|
||||||
|
return props.report.user?.avatar_url || "";
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportItemTitle = computed(() => {
|
||||||
|
if (props.report.item_type === "user") return props.report.user?.username || "Unknown User";
|
||||||
|
|
||||||
|
return props.report.project?.title || "Unknown Project";
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportItemUrl = computed(() => {
|
||||||
|
switch (props.report.item_type) {
|
||||||
|
case "user":
|
||||||
|
return `/user/${props.report.user?.username}`;
|
||||||
|
case "project":
|
||||||
|
return `/${props.report.project?.project_type}/${props.report.project?.slug}`;
|
||||||
|
case "version":
|
||||||
|
return `/${props.report.project?.project_type}/${props.report.project?.slug}/versions/${props.report.version?.id}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedItemType = computed(() => {
|
||||||
|
const itemType = props.report.item_type;
|
||||||
|
return itemType.charAt(0).toUpperCase() + itemType.slice(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedReportType = computed(() => {
|
||||||
|
const reportType = props.report.report_type;
|
||||||
|
|
||||||
|
// some are split by -, some are split by " "
|
||||||
|
const words = reportType.includes("-") ? reportType.split("-") : reportType.split(" ");
|
||||||
|
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<NewModal ref="modal" header="Moderation shortcuts" :closable="true">
|
||||||
|
<div>
|
||||||
|
<div class="keybinds-sections">
|
||||||
|
<div class="grid grid-cols-2 gap-x-12 gap-y-3">
|
||||||
|
<div
|
||||||
|
v-for="keybind in keybinds"
|
||||||
|
:key="keybind.id"
|
||||||
|
class="keybind-item flex items-center justify-between gap-4"
|
||||||
|
:class="{
|
||||||
|
'col-span-2': keybinds.length % 2 === 1 && keybinds[keybinds.length - 1] === keybind,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-secondary">{{ keybind.description }}</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<kbd
|
||||||
|
v-for="(key, index) in parseKeybindDisplay(keybind.keybind)"
|
||||||
|
:key="`${keybind.id}-key-${index}`"
|
||||||
|
class="keybind-key"
|
||||||
|
>
|
||||||
|
{{ key }}
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
|
||||||
|
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>();
|
||||||
|
|
||||||
|
function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
|
||||||
|
const keybinds = Array.isArray(keybind) ? keybind : [keybind];
|
||||||
|
const normalized = keybinds[0];
|
||||||
|
const def = normalizeKeybind(normalized);
|
||||||
|
|
||||||
|
const keys = [];
|
||||||
|
|
||||||
|
if (def.ctrl || def.meta) {
|
||||||
|
keys.push(isMac() ? "CMD" : "CTRL");
|
||||||
|
}
|
||||||
|
if (def.shift) keys.push("SHIFT");
|
||||||
|
if (def.alt) keys.push("ALT");
|
||||||
|
|
||||||
|
const mainKey = def.key
|
||||||
|
.replace("ArrowLeft", "←")
|
||||||
|
.replace("ArrowRight", "→")
|
||||||
|
.replace("ArrowUp", "↑")
|
||||||
|
.replace("ArrowDown", "↓")
|
||||||
|
.replace("Enter", "↵")
|
||||||
|
.replace("Space", "SPACE")
|
||||||
|
.replace("Escape", "ESC")
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
keys.push(mainKey);
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMac() {
|
||||||
|
return navigator.platform.toUpperCase().includes("MAC");
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(event?: MouseEvent) {
|
||||||
|
modal.value?.show(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
modal.value?.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
show,
|
||||||
|
hide,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.keybind-key {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-divider);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-contrast);
|
||||||
|
|
||||||
|
+ .keybind-key {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.keybind-item {
|
||||||
|
min-height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.keybinds-sections {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,513 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
|
||||||
|
Modpack permissions ({{ Math.min(modPackData.length, currentIndex + 1) }} /
|
||||||
|
{{ modPackData.length }})
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div v-if="!modPackData">Loading data...</div>
|
||||||
|
|
||||||
|
<div v-else-if="modPackData.length === 0">
|
||||||
|
<p>All permissions already obtained.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!modPackData[currentIndex]">
|
||||||
|
<p>All permission checks complete!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div v-if="modPackData[currentIndex].type === 'unknown'">
|
||||||
|
<p>What is the approval type of {{ modPackData[currentIndex].file_name }}?</p>
|
||||||
|
<div class="input-group">
|
||||||
|
<ButtonStyled
|
||||||
|
v-for="(option, index) in fileApprovalTypes"
|
||||||
|
:key="index"
|
||||||
|
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
|
||||||
|
@click="setStatus(currentIndex, option.id)"
|
||||||
|
>
|
||||||
|
<button>
|
||||||
|
{{ option.name }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
<div v-if="modPackData[currentIndex].status !== 'unidentified'" class="flex flex-col gap-1">
|
||||||
|
<label for="proof">
|
||||||
|
<span class="label__title">Proof</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="proof"
|
||||||
|
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).proof"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Enter proof of status..."
|
||||||
|
@input="persistAll()"
|
||||||
|
/>
|
||||||
|
<label for="link">
|
||||||
|
<span class="label__title">Link</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="link"
|
||||||
|
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).url"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Enter link of project..."
|
||||||
|
@input="persistAll()"
|
||||||
|
/>
|
||||||
|
<label for="title">
|
||||||
|
<span class="label__title">Title</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).title"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Enter title of project..."
|
||||||
|
@input="persistAll()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="modPackData[currentIndex].type === 'flame'">
|
||||||
|
<p>
|
||||||
|
What is the approval type of {{ modPackData[currentIndex].title }} (<a
|
||||||
|
:href="modPackData[currentIndex].url"
|
||||||
|
target="_blank"
|
||||||
|
class="text-link"
|
||||||
|
>{{ modPackData[currentIndex].url }}</a
|
||||||
|
>)?
|
||||||
|
</p>
|
||||||
|
<div class="input-group">
|
||||||
|
<ButtonStyled
|
||||||
|
v-for="(option, index) in fileApprovalTypes"
|
||||||
|
:key="index"
|
||||||
|
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
|
||||||
|
@click="setStatus(currentIndex, option.id)"
|
||||||
|
>
|
||||||
|
<button>
|
||||||
|
{{ option.name }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
['unidentified', 'no', 'with-attribution'].includes(
|
||||||
|
modPackData[currentIndex].status || '',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p v-if="modPackData[currentIndex].status === 'unidentified'">
|
||||||
|
Does this project provide identification and permission for
|
||||||
|
<strong>{{ modPackData[currentIndex].file_name }}</strong
|
||||||
|
>?
|
||||||
|
</p>
|
||||||
|
<p v-else-if="modPackData[currentIndex].status === 'with-attribution'">
|
||||||
|
Does this project provide attribution for
|
||||||
|
<strong>{{ modPackData[currentIndex].file_name }}</strong
|
||||||
|
>?
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
Does this project provide proof of permission for
|
||||||
|
<strong>{{ modPackData[currentIndex].file_name }}</strong
|
||||||
|
>?
|
||||||
|
</p>
|
||||||
|
<div class="input-group">
|
||||||
|
<ButtonStyled
|
||||||
|
v-for="(option, index) in filePermissionTypes"
|
||||||
|
:key="index"
|
||||||
|
:color="modPackData[currentIndex].approved === option.id ? 'brand' : 'standard'"
|
||||||
|
@click="setApproval(currentIndex, option.id)"
|
||||||
|
>
|
||||||
|
<button>
|
||||||
|
{{ option.name }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<ButtonStyled>
|
||||||
|
<button :disabled="currentIndex <= 0" @click="goToPrevious">
|
||||||
|
<LeftArrowIcon aria-hidden="true" />
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-if="modPackData && currentIndex < modPackData.length" color="blue">
|
||||||
|
<button :disabled="!canGoNext" @click="goToNext">
|
||||||
|
<RightArrowIcon aria-hidden="true" />
|
||||||
|
{{ currentIndex + 1 >= modPackData.length ? "Complete" : "Next" }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { LeftArrowIcon, RightArrowIcon } from "@modrinth/assets";
|
||||||
|
import type {
|
||||||
|
ModerationJudgements,
|
||||||
|
ModerationModpackItem,
|
||||||
|
ModerationModpackResponse,
|
||||||
|
ModerationUnknownModpackItem,
|
||||||
|
ModerationFlameModpackItem,
|
||||||
|
ModerationModpackPermissionApprovalType,
|
||||||
|
ModerationPermissionType,
|
||||||
|
} from "@modrinth/utils";
|
||||||
|
import { ButtonStyled } from "@modrinth/ui";
|
||||||
|
import { ref, computed, watch, onMounted } from "vue";
|
||||||
|
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projectId: string;
|
||||||
|
modelValue?: ModerationJudgements;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
complete: [];
|
||||||
|
"update:modelValue": [judgements: ModerationJudgements];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
|
||||||
|
`modpack-permissions-${props.projectId}`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
serializer: {
|
||||||
|
read: (v: any) => (v ? JSON.parse(v) : null),
|
||||||
|
write: (v: any) => JSON.stringify(v),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
|
||||||
|
|
||||||
|
const modPackData = useSessionStorage<ModerationModpackItem[] | null>(
|
||||||
|
`modpack-permissions-data-${props.projectId}`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
serializer: {
|
||||||
|
read: (v: any) => (v ? JSON.parse(v) : null),
|
||||||
|
write: (v: any) => JSON.stringify(v),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
|
||||||
|
`modpack-permissions-permanent-no-${props.projectId}`,
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
serializer: {
|
||||||
|
read: (v: any) => (v ? JSON.parse(v) : []),
|
||||||
|
write: (v: any) => JSON.stringify(v),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const currentIndex = ref(0);
|
||||||
|
|
||||||
|
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
|
||||||
|
{
|
||||||
|
id: "yes",
|
||||||
|
name: "Yes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "with-attribution-and-source",
|
||||||
|
name: "With attribution and source",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "with-attribution",
|
||||||
|
name: "With attribution",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "no",
|
||||||
|
name: "No",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "permanent-no",
|
||||||
|
name: "Permanent no",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unidentified",
|
||||||
|
name: "Unidentified",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filePermissionTypes: ModerationPermissionType[] = [
|
||||||
|
{ id: "yes", name: "Yes" },
|
||||||
|
{ id: "no", name: "No" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function persistAll() {
|
||||||
|
persistedModPackData.value = modPackData.value;
|
||||||
|
persistedIndex.value = currentIndex.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
modPackData,
|
||||||
|
(newValue) => {
|
||||||
|
persistedModPackData.value = newValue;
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(currentIndex, (newValue) => {
|
||||||
|
persistedIndex.value = newValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadPersistedData(): void {
|
||||||
|
if (persistedModPackData.value) {
|
||||||
|
modPackData.value = persistedModPackData.value;
|
||||||
|
}
|
||||||
|
currentIndex.value = persistedIndex.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPersistedData(): void {
|
||||||
|
persistedModPackData.value = null;
|
||||||
|
persistedIndex.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchModPackData(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
|
||||||
|
internal: true,
|
||||||
|
})) as ModerationModpackResponse;
|
||||||
|
|
||||||
|
const permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {})
|
||||||
|
.filter(([_, file]) => file.status === "permanent-no")
|
||||||
|
.map(
|
||||||
|
([sha1, file]): ModerationModpackItem => ({
|
||||||
|
sha1,
|
||||||
|
file_name: file.file_name,
|
||||||
|
type: "identified",
|
||||||
|
status: file.status,
|
||||||
|
approved: null,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
||||||
|
|
||||||
|
permanentNoFiles.value = permanentNoItems;
|
||||||
|
|
||||||
|
const sortedData: ModerationModpackItem[] = [
|
||||||
|
...Object.entries(data.identified || {})
|
||||||
|
.filter(
|
||||||
|
([_, file]) =>
|
||||||
|
file.status !== "yes" &&
|
||||||
|
file.status !== "with-attribution-and-source" &&
|
||||||
|
file.status !== "permanent-no",
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
([sha1, file]): ModerationModpackItem => ({
|
||||||
|
sha1,
|
||||||
|
file_name: file.file_name,
|
||||||
|
type: "identified",
|
||||||
|
status: file.status,
|
||||||
|
approved: null,
|
||||||
|
...(file.status === "unidentified" && {
|
||||||
|
proof: "",
|
||||||
|
url: "",
|
||||||
|
title: "",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
|
||||||
|
...Object.entries(data.unknown_files || {})
|
||||||
|
.map(
|
||||||
|
([sha1, fileName]): ModerationUnknownModpackItem => ({
|
||||||
|
sha1,
|
||||||
|
file_name: fileName,
|
||||||
|
type: "unknown",
|
||||||
|
status: null,
|
||||||
|
approved: null,
|
||||||
|
proof: "",
|
||||||
|
url: "",
|
||||||
|
title: "",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
|
||||||
|
...Object.entries(data.flame_files || {})
|
||||||
|
.map(
|
||||||
|
([sha1, info]): ModerationFlameModpackItem => ({
|
||||||
|
sha1,
|
||||||
|
file_name: info.file_name,
|
||||||
|
type: "flame",
|
||||||
|
status: null,
|
||||||
|
approved: null,
|
||||||
|
id: info.id,
|
||||||
|
title: info.title || info.file_name,
|
||||||
|
url: info.url || `https://www.curseforge.com/minecraft/mc-mods/${info.id}`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (modPackData.value) {
|
||||||
|
const existingMap = new Map(modPackData.value.map((item) => [item.sha1, item]));
|
||||||
|
|
||||||
|
sortedData.forEach((item) => {
|
||||||
|
const existing = existingMap.get(item.sha1);
|
||||||
|
if (existing) {
|
||||||
|
Object.assign(item, {
|
||||||
|
status: existing.status,
|
||||||
|
approved: existing.approved,
|
||||||
|
...(item.type === "unknown" && {
|
||||||
|
proof: (existing as ModerationUnknownModpackItem).proof || "",
|
||||||
|
url: (existing as ModerationUnknownModpackItem).url || "",
|
||||||
|
title: (existing as ModerationUnknownModpackItem).title || "",
|
||||||
|
}),
|
||||||
|
...(item.type === "flame" && {
|
||||||
|
url: (existing as ModerationFlameModpackItem).url || item.url,
|
||||||
|
title: (existing as ModerationFlameModpackItem).title || item.title,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
modPackData.value = sortedData;
|
||||||
|
persistAll();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch modpack data:", error);
|
||||||
|
modPackData.value = [];
|
||||||
|
permanentNoFiles.value = [];
|
||||||
|
persistAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPrevious(): void {
|
||||||
|
if (currentIndex.value > 0) {
|
||||||
|
currentIndex.value--;
|
||||||
|
persistAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
modPackData,
|
||||||
|
(newValue) => {
|
||||||
|
persistedModPackData.value = newValue;
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function goToNext(): void {
|
||||||
|
if (modPackData.value && currentIndex.value < modPackData.value.length) {
|
||||||
|
currentIndex.value++;
|
||||||
|
|
||||||
|
if (currentIndex.value >= modPackData.value.length) {
|
||||||
|
const judgements = getJudgements();
|
||||||
|
emit("update:modelValue", judgements);
|
||||||
|
emit("complete");
|
||||||
|
clearPersistedData();
|
||||||
|
} else {
|
||||||
|
persistAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(index: number, status: ModerationModpackPermissionApprovalType["id"]): void {
|
||||||
|
if (modPackData.value && modPackData.value[index]) {
|
||||||
|
modPackData.value[index].status = status;
|
||||||
|
modPackData.value[index].approved = null;
|
||||||
|
persistAll();
|
||||||
|
emit("update:modelValue", getJudgements());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setApproval(index: number, approved: ModerationPermissionType["id"]): void {
|
||||||
|
if (modPackData.value && modPackData.value[index]) {
|
||||||
|
modPackData.value[index].approved = approved;
|
||||||
|
persistAll();
|
||||||
|
emit("update:modelValue", getJudgements());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canGoNext = computed(() => {
|
||||||
|
if (!modPackData.value || !modPackData.value[currentIndex.value]) return false;
|
||||||
|
const current = modPackData.value[currentIndex.value];
|
||||||
|
return current.status !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getJudgements(): ModerationJudgements {
|
||||||
|
if (!modPackData.value) return {};
|
||||||
|
|
||||||
|
const judgements: ModerationJudgements = {};
|
||||||
|
|
||||||
|
modPackData.value.forEach((item) => {
|
||||||
|
if (item.type === "flame") {
|
||||||
|
judgements[item.sha1] = {
|
||||||
|
type: "flame",
|
||||||
|
id: item.id,
|
||||||
|
status: item.status,
|
||||||
|
link: item.url,
|
||||||
|
title: item.title,
|
||||||
|
file_name: item.file_name,
|
||||||
|
};
|
||||||
|
} else if (item.type === "unknown") {
|
||||||
|
judgements[item.sha1] = {
|
||||||
|
type: "unknown",
|
||||||
|
status: item.status,
|
||||||
|
proof: item.proof,
|
||||||
|
link: item.url,
|
||||||
|
title: item.title,
|
||||||
|
file_name: item.file_name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return judgements;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadPersistedData();
|
||||||
|
if (!modPackData.value) {
|
||||||
|
fetchModPackData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
modPackData,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue && newValue.length === 0) {
|
||||||
|
emit("complete");
|
||||||
|
clearPersistedData();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.projectId,
|
||||||
|
() => {
|
||||||
|
clearPersistedData();
|
||||||
|
loadPersistedData();
|
||||||
|
if (!modPackData.value) {
|
||||||
|
fetchModPackData();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function getModpackFiles(): {
|
||||||
|
interactive: ModerationModpackItem[];
|
||||||
|
permanentNo: ModerationModpackItem[];
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
interactive: modPackData.value || [],
|
||||||
|
permanentNo: permanentNoFiles.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
getModpackFiles,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modpack-buttons {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -172,6 +172,7 @@ const flags = useFeatureFlags();
|
|||||||
|
|
||||||
.markdown-body {
|
.markdown-body {
|
||||||
grid-area: body;
|
grid-area: body;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporter-info {
|
.reporter-info {
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<template v-if="moderation">
|
||||||
|
<Chips v-model="reasonFilter" :items="reasons" />
|
||||||
|
<p v-if="reports.length === MAX_REPORTS" class="text-red">
|
||||||
|
There are at least {{ MAX_REPORTS }} open reports. This page is at its max reports and will
|
||||||
|
not show any more recent ones.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="reasonFilter === 'All'">There are {{ filteredReports.length }} open reports.</p>
|
||||||
|
<p v-else>
|
||||||
|
There are {{ filteredReports.length }}/{{ reports.length }} open '{{ reasonFilter }}' reports.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
<ReportInfo
|
<ReportInfo
|
||||||
v-for="report in reports.filter(
|
v-for="report in filteredReports"
|
||||||
(x) =>
|
|
||||||
(moderation || x.reporterUser.id === auth.user.id) &&
|
|
||||||
(viewMode === 'open' ? x.open : !x.open),
|
|
||||||
)"
|
|
||||||
:key="report.id"
|
:key="report.id"
|
||||||
:report="report"
|
:report="report"
|
||||||
:thread="report.thread"
|
:thread="report.thread"
|
||||||
|
:show-message="false"
|
||||||
:moderation="moderation"
|
:moderation="moderation"
|
||||||
raised
|
raised
|
||||||
:auth="auth"
|
:auth="auth"
|
||||||
@@ -16,11 +24,12 @@
|
|||||||
<p v-if="reports.length === 0">You don't have any active reports.</p>
|
<p v-if="reports.length === 0">You don't have any active reports.</p>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { Chips } from "@modrinth/ui";
|
||||||
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
|
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
|
||||||
import { addReportMessage } from "~/helpers/threads.js";
|
import { addReportMessage } from "~/helpers/threads.js";
|
||||||
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
|
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
moderation: {
|
moderation: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -32,9 +41,14 @@ defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const viewMode = ref("open");
|
const viewMode = ref("open");
|
||||||
|
const reasonFilter = ref("All");
|
||||||
const reports = ref([]);
|
const reports = ref([]);
|
||||||
|
|
||||||
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report?count=1000"));
|
const MAX_REPORTS = 1500;
|
||||||
|
|
||||||
|
let { data: rawReports } = await useAsyncData("report", () =>
|
||||||
|
useBaseFetch(`report?count=${MAX_REPORTS}`),
|
||||||
|
);
|
||||||
|
|
||||||
rawReports = rawReports.value.map((report) => {
|
rawReports = rawReports.value.map((report) => {
|
||||||
report.item_id = report.item_id.replace(/"/g, "");
|
report.item_id = report.item_id.replace(/"/g, "");
|
||||||
@@ -51,6 +65,7 @@ const userIds = [...new Set(reporterUsers.concat(reportedUsers))];
|
|||||||
const threadIds = [
|
const threadIds = [
|
||||||
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
|
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
|
||||||
];
|
];
|
||||||
|
const reasons = ["All", ...new Set(rawReports.map((report) => report.report_type))];
|
||||||
|
|
||||||
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
|
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
|
||||||
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
|
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
|
||||||
@@ -93,4 +108,13 @@ reports.value = rawReports.map((report) => {
|
|||||||
report.open = true;
|
report.open = true;
|
||||||
return report;
|
return report;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const filteredReports = computed(() =>
|
||||||
|
reports.value?.filter(
|
||||||
|
(x) =>
|
||||||
|
(props.moderation || x.reporterUser.id === props.auth.user.id) &&
|
||||||
|
(viewMode.value === "open" ? x.open : !x.open) &&
|
||||||
|
(reasonFilter.value === "All" || reasonFilter.value === x.report_type),
|
||||||
|
),
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -31,9 +31,9 @@
|
|||||||
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
|
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
|
||||||
@click="
|
@click="
|
||||||
versionFilter &&
|
versionFilter &&
|
||||||
(unlockFilterAccordion.isOpen
|
(unlockFilterAccordion.isOpen
|
||||||
? unlockFilterAccordion.close()
|
? unlockFilterAccordion.close()
|
||||||
: unlockFilterAccordion.open())
|
: unlockFilterAccordion.open())
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<TagItem
|
<TagItem
|
||||||
|
|||||||
@@ -66,6 +66,27 @@
|
|||||||
<UiServersPanelSpinner />
|
<UiServersPanelSpinner />
|
||||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="status === 'suspended' && suspension_reason === 'cancelled'"
|
||||||
|
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||||
|
>
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been cancelled. Please
|
||||||
|
update your billing information or contact Modrinth Support for more information.
|
||||||
|
</div>
|
||||||
|
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="status === 'suspended' && suspension_reason"
|
||||||
|
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||||
|
>
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended:
|
||||||
|
{{ suspension_reason }}. Please update your billing information or contact Modrinth Support
|
||||||
|
for more information.
|
||||||
|
</div>
|
||||||
|
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="status === 'suspended'"
|
v-else-if="status === 'suspended'"
|
||||||
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||||
@@ -87,7 +108,8 @@ import { Avatar, CopyCode } from "@modrinth/ui";
|
|||||||
|
|
||||||
const props = defineProps<Partial<Server>>();
|
const props = defineProps<Partial<Server>>();
|
||||||
|
|
||||||
if (props.server_id) {
|
if (props.server_id && props.status === "available") {
|
||||||
|
// Necessary only to get server icon
|
||||||
await useModrinthServers(props.server_id, ["general"]);
|
await useModrinthServers(props.server_id, ["general"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,11 +131,6 @@ if (props.upstream) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
|
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
|
||||||
|
|
||||||
if (import.meta.server && projectData.value?.icon_url) {
|
|
||||||
await useModrinthServers(props.server_id!, ["general"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
|
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
|
||||||
const isConfiguring = computed(() => props.flows?.intro);
|
const isConfiguring = computed(() => props.flows?.intro);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -34,6 +34,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal ref="modalReply" header="Reply to thread">
|
||||||
|
<div class="modal-submit universal-body">
|
||||||
|
<span>
|
||||||
|
Your project is already approved. As such, the moderation team does not actively monitor
|
||||||
|
this thread. However, they may still see your message if there is a problem with your
|
||||||
|
project.
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
If you need to get in contact with the moderation team, please use the
|
||||||
|
<a class="text-link" href="https://support.modrinth.com" target="_blank">
|
||||||
|
Modrinth Help Center
|
||||||
|
</a>
|
||||||
|
and click the green bubble to contact support.
|
||||||
|
</span>
|
||||||
|
<Checkbox
|
||||||
|
v-model="replyConfirmation"
|
||||||
|
description="Confirm moderators do not actively monitor this"
|
||||||
|
>
|
||||||
|
I acknowledge that the moderators do not actively monitor the thread.
|
||||||
|
</Checkbox>
|
||||||
|
<div class="input-group push-right">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="!replyConfirmation"
|
||||||
|
@click="sendReplyFromModal()"
|
||||||
|
>
|
||||||
|
<ReplyIcon aria-hidden="true" />
|
||||||
|
Reply to thread
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
<div v-if="flags.developerMode" class="thread-id">
|
<div v-if="flags.developerMode" class="thread-id">
|
||||||
Thread ID:
|
Thread ID:
|
||||||
<CopyCode :text="thread.id" />
|
<CopyCode :text="thread.id" />
|
||||||
@@ -71,12 +103,17 @@
|
|||||||
v-if="sortedMessages.length > 0"
|
v-if="sortedMessages.length > 0"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
:disabled="!replyBody"
|
:disabled="!replyBody"
|
||||||
@click="sendReply()"
|
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
|
||||||
>
|
>
|
||||||
<ReplyIcon aria-hidden="true" />
|
<ReplyIcon aria-hidden="true" />
|
||||||
Reply
|
Reply
|
||||||
</button>
|
</button>
|
||||||
<button v-else class="btn btn-primary" :disabled="!replyBody" @click="sendReply()">
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="!replyBody"
|
||||||
|
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
|
||||||
|
>
|
||||||
<SendIcon aria-hidden="true" />
|
<SendIcon aria-hidden="true" />
|
||||||
Send
|
Send
|
||||||
</button>
|
</button>
|
||||||
@@ -289,6 +326,7 @@ const sortedMessages = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const modalSubmit = ref(null);
|
const modalSubmit = ref(null);
|
||||||
|
const modalReply = ref(null);
|
||||||
|
|
||||||
async function updateThreadLocal() {
|
async function updateThreadLocal() {
|
||||||
let threadId = null;
|
let threadId = null;
|
||||||
@@ -316,6 +354,11 @@ async function onUploadImage(file) {
|
|||||||
return response.url;
|
return response.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendReplyFromModal(status = null, privateMessage = false) {
|
||||||
|
modalReply.value.hide();
|
||||||
|
await sendReply(status, privateMessage);
|
||||||
|
}
|
||||||
|
|
||||||
async function sendReply(status = null, privateMessage = false) {
|
async function sendReply(status = null, privateMessage = false) {
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
@@ -398,6 +441,7 @@ async function reopenReport() {
|
|||||||
|
|
||||||
const replyWithSubmission = ref(false);
|
const replyWithSubmission = ref(false);
|
||||||
const submissionConfirmation = ref(false);
|
const submissionConfirmation = ref(false);
|
||||||
|
const replyConfirmation = ref(false);
|
||||||
|
|
||||||
function openResubmitModal(reply) {
|
function openResubmitModal(reply) {
|
||||||
submissionConfirmation.value = false;
|
submissionConfirmation.value = false;
|
||||||
@@ -405,6 +449,11 @@ function openResubmitModal(reply) {
|
|||||||
modalSubmit.value.show();
|
modalSubmit.value.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openReplyModal(reply) {
|
||||||
|
replyConfirmation.value = false;
|
||||||
|
modalReply.value.show();
|
||||||
|
}
|
||||||
|
|
||||||
async function resubmit() {
|
async function resubmit() {
|
||||||
if (replyWithSubmission.value) {
|
if (replyWithSubmission.value) {
|
||||||
await sendReply("processing");
|
await sendReply("processing");
|
||||||
|
|||||||
282
apps/frontend/src/components/ui/thread/ReportThread.vue
Normal file
282
apps/frontend/src/components/ui/thread/ReportThread.vue
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="flags.developerMode" class="mb-4 font-bold text-heading">
|
||||||
|
Thread ID:
|
||||||
|
<CopyCode :text="thread.id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="sortedMessages.length > 0"
|
||||||
|
class="bg-raised flex flex-col space-y-4 rounded-xl p-3 sm:p-4"
|
||||||
|
>
|
||||||
|
<ThreadMessage
|
||||||
|
v-for="message in sortedMessages"
|
||||||
|
:key="'message-' + message.id"
|
||||||
|
:thread="thread"
|
||||||
|
:message="message"
|
||||||
|
:members="members"
|
||||||
|
:report="report"
|
||||||
|
:auth="auth"
|
||||||
|
raised
|
||||||
|
@update-thread="() => updateThreadLocal()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="reportClosed">
|
||||||
|
<p class="text-secondary">This thread is closed and new messages cannot be sent to it.</p>
|
||||||
|
<ButtonStyled v-if="isStaff(auth.user)" color="green" class="mt-2 w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||||
|
@click="reopenReport()"
|
||||||
|
>
|
||||||
|
<CheckCircleIcon class="size-4" />
|
||||||
|
Reopen Thread
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mt-4">
|
||||||
|
<MarkdownEditor
|
||||||
|
v-model="replyBody"
|
||||||
|
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
|
||||||
|
:on-image-upload="onUploadImage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-4 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center sm:gap-2"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||||
|
<ButtonStyled v-if="sortedMessages.length > 0" color="brand" class="w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
:disabled="!replyBody"
|
||||||
|
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||||
|
@click="sendReply()"
|
||||||
|
>
|
||||||
|
<ReplyIcon class="size-4" />
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-else color="brand" class="w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
:disabled="!replyBody"
|
||||||
|
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||||
|
@click="sendReply()"
|
||||||
|
>
|
||||||
|
<SendIcon class="size-4" />
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-if="isStaff(auth.user)" class="w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
:disabled="!replyBody"
|
||||||
|
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||||
|
@click="sendReply(true)"
|
||||||
|
>
|
||||||
|
<ScaleIcon class="size-4" />
|
||||||
|
<span class="hidden sm:inline">Add private note</span>
|
||||||
|
<span class="sm:hidden">Private note</span>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||||
|
<template v-if="isStaff(auth.user)">
|
||||||
|
<ButtonStyled v-if="replyBody" color="red" class="w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||||
|
@click="closeReport(true)"
|
||||||
|
>
|
||||||
|
<CheckCircleIcon class="size-4" />
|
||||||
|
<span class="hidden sm:inline">Close with reply</span>
|
||||||
|
<span class="sm:hidden">Close & reply</span>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-else color="red" class="w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||||
|
@click="closeReport()"
|
||||||
|
>
|
||||||
|
<CheckCircleIcon class="size-4" />
|
||||||
|
Close report
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CopyCode, MarkdownEditor, ButtonStyled } from "@modrinth/ui";
|
||||||
|
import { ReplyIcon, SendIcon, CheckCircleIcon, ScaleIcon } from "@modrinth/assets";
|
||||||
|
import type { Thread, Report, User, ThreadMessage as TypeThreadMessage } from "@modrinth/utils";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import ThreadMessage from "./ThreadMessage.vue";
|
||||||
|
import { useImageUpload } from "~/composables/image-upload.ts";
|
||||||
|
import { isStaff } from "~/helpers/users.js";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
thread: Thread;
|
||||||
|
reporter: User;
|
||||||
|
report: Report;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const auth = await useAuth();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
updateThread: [thread: Thread];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const flags = useFeatureFlags();
|
||||||
|
|
||||||
|
const members = computed(() => {
|
||||||
|
const membersMap: Record<string, User> = {
|
||||||
|
[props.reporter.id]: props.reporter,
|
||||||
|
};
|
||||||
|
for (const member of props.thread.members) {
|
||||||
|
membersMap[member.id] = member;
|
||||||
|
}
|
||||||
|
return membersMap;
|
||||||
|
});
|
||||||
|
|
||||||
|
const replyBody = ref("");
|
||||||
|
function setReplyContent(content: string) {
|
||||||
|
replyBody.value = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
setReplyContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedMessages = computed(() => {
|
||||||
|
const messages: TypeThreadMessage[] = [
|
||||||
|
{
|
||||||
|
id: null,
|
||||||
|
author_id: props.reporter.id,
|
||||||
|
body: {
|
||||||
|
type: "text",
|
||||||
|
body: props.report.body || "Report opened.",
|
||||||
|
private: false,
|
||||||
|
replying_to: null,
|
||||||
|
associated_images: [],
|
||||||
|
},
|
||||||
|
created: props.report.created,
|
||||||
|
hide_identity: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (props.thread) {
|
||||||
|
messages.push(
|
||||||
|
...[...props.thread.messages].sort(
|
||||||
|
(a, b) => dayjs(a.created).toDate().getTime() - dayjs(b.created).toDate().getTime(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function updateThreadLocal() {
|
||||||
|
const threadId = props.report.thread_id;
|
||||||
|
if (threadId) {
|
||||||
|
try {
|
||||||
|
const thread = (await useBaseFetch(`thread/${threadId}`)) as Thread;
|
||||||
|
emit("updateThread", thread);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update thread:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageIDs = ref<string[]>([]);
|
||||||
|
|
||||||
|
async function onUploadImage(file: File) {
|
||||||
|
const response = await useImageUpload(file, { context: "thread_message" });
|
||||||
|
|
||||||
|
imageIDs.value.push(response.id);
|
||||||
|
imageIDs.value = imageIDs.value.slice(-10);
|
||||||
|
|
||||||
|
return response.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendReply(privateMessage = false) {
|
||||||
|
try {
|
||||||
|
const body: any = {
|
||||||
|
body: {
|
||||||
|
type: "text",
|
||||||
|
body: replyBody.value,
|
||||||
|
private: privateMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (imageIDs.value.length > 0) {
|
||||||
|
body.body = {
|
||||||
|
...body.body,
|
||||||
|
uploaded_images: imageIDs.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await useBaseFetch(`thread/${props.thread.id}`, {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
replyBody.value = "";
|
||||||
|
await updateThreadLocal();
|
||||||
|
} catch (err: any) {
|
||||||
|
addNotification({
|
||||||
|
title: "Error sending message",
|
||||||
|
text: err.data ? err.data.description : err,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const didCloseReport = ref(false);
|
||||||
|
const reportClosed = computed(() => {
|
||||||
|
return didCloseReport.value || (props.report && props.report.closed);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function closeReport(reply = false) {
|
||||||
|
if (reply) {
|
||||||
|
await sendReply();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await useBaseFetch(`report/${props.report.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: {
|
||||||
|
closed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await updateThreadLocal();
|
||||||
|
didCloseReport.value = true;
|
||||||
|
} catch (err: any) {
|
||||||
|
addNotification({
|
||||||
|
title: "Error closing report",
|
||||||
|
text: err.data ? err.data.description : err,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reopenReport() {
|
||||||
|
try {
|
||||||
|
await useBaseFetch(`report/${props.report.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: {
|
||||||
|
closed: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await updateThreadLocal();
|
||||||
|
} catch (err: any) {
|
||||||
|
addNotification({
|
||||||
|
title: "Error reopening report",
|
||||||
|
text: err.data ? err.data.description : err,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
v-tooltip="'Modrinth Team'"
|
v-tooltip="'Modrinth Team'"
|
||||||
/>
|
/>
|
||||||
<MicrophoneIcon
|
<MicrophoneIcon
|
||||||
v-if="report && message.author_id === report.reporterUser.id"
|
v-if="report && message.author_id === report.reporter_user?.id"
|
||||||
v-tooltip="'Reporter'"
|
v-tooltip="'Reporter'"
|
||||||
class="reporter-icon"
|
class="reporter-icon"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export class ModrinthServer {
|
|||||||
try {
|
try {
|
||||||
const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
|
const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
|
||||||
override: auth,
|
override: auth,
|
||||||
retry: false,
|
retry: 1, // Reduce retries for optional resources
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fileData instanceof Blob && import.meta.client) {
|
if (fileData instanceof Blob && import.meta.client) {
|
||||||
@@ -124,8 +124,14 @@ export class ModrinthServer {
|
|||||||
return dataURL;
|
return dataURL;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ModrinthServerError && error.statusCode === 404) {
|
if (error instanceof ModrinthServerError) {
|
||||||
if (iconUrl) {
|
if (error.statusCode && error.statusCode >= 500) {
|
||||||
|
console.debug("Service unavailable, skipping icon processing");
|
||||||
|
sharedImage.value = undefined;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.statusCode === 404 && iconUrl) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(iconUrl);
|
const response = await fetch(iconUrl);
|
||||||
if (!response.ok) throw new Error("Failed to fetch icon");
|
if (!response.ok) throw new Error("Failed to fetch icon");
|
||||||
@@ -187,6 +193,44 @@ export class ModrinthServer {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async testNodeReachability(): Promise<boolean> {
|
||||||
|
if (!this.general?.node?.instance) {
|
||||||
|
console.warn("No node instance available for ping test");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsUrl = `wss://${this.general.node.instance}/pingtest`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const socket = new WebSocket(wsUrl);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
socket.close();
|
||||||
|
resolve(false);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
socket.send(performance.now().toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
socket.close();
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to ping node ${wsUrl}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async refresh(
|
async refresh(
|
||||||
modules: ModuleName[] = [],
|
modules: ModuleName[] = [],
|
||||||
options?: {
|
options?: {
|
||||||
@@ -200,6 +244,8 @@ export class ModrinthServer {
|
|||||||
: (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]);
|
: (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]);
|
||||||
|
|
||||||
for (const module of modulesToRefresh) {
|
for (const module of modulesToRefresh) {
|
||||||
|
this.errors[module] = undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (module) {
|
switch (module) {
|
||||||
case "general": {
|
case "general": {
|
||||||
@@ -250,7 +296,7 @@ export class ModrinthServer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.statusCode === 503) {
|
if (error.statusCode && error.statusCode >= 500) {
|
||||||
console.debug(`Temporary ${module} unavailable:`, error.message);
|
console.debug(`Temporary ${module} unavailable:`, error.message);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,26 +22,49 @@ export class FSModule extends ServerModule {
|
|||||||
this.opsQueuedForModification = [];
|
this.opsQueuedForModification = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async retryWithAuth<T>(requestFn: () => Promise<T>): Promise<T> {
|
private async retryWithAuth<T>(
|
||||||
|
requestFn: () => Promise<T>,
|
||||||
|
ignoreFailure: boolean = false,
|
||||||
|
): Promise<T> {
|
||||||
try {
|
try {
|
||||||
return await requestFn();
|
return await requestFn();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ModrinthServerError && error.statusCode === 401) {
|
if (error instanceof ModrinthServerError && error.statusCode === 401) {
|
||||||
|
console.debug("Auth failed, refreshing JWT and retrying");
|
||||||
await this.fetch(); // Refresh auth
|
await this.fetch(); // Refresh auth
|
||||||
return await requestFn();
|
return await requestFn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const available = await this.server.testNodeReachability();
|
||||||
|
if (!available && !ignoreFailure) {
|
||||||
|
this.server.moduleErrors.general = {
|
||||||
|
error: new ModrinthServerError(
|
||||||
|
"Unable to reach node. FS operation failed and subsequent ping test failed.",
|
||||||
|
500,
|
||||||
|
error as Error,
|
||||||
|
"fs",
|
||||||
|
),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
listDirContents(path: string, page: number, pageSize: number): Promise<DirectoryResponse> {
|
listDirContents(
|
||||||
|
path: string,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
ignoreFailure: boolean = false,
|
||||||
|
): Promise<DirectoryResponse> {
|
||||||
return this.retryWithAuth(async () => {
|
return this.retryWithAuth(async () => {
|
||||||
const encodedPath = encodeURIComponent(path);
|
const encodedPath = encodeURIComponent(path);
|
||||||
return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
|
return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
|
||||||
override: this.auth,
|
override: this.auth,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
});
|
}, ignoreFailure);
|
||||||
}
|
}
|
||||||
|
|
||||||
createFileOrFolder(path: string, type: "file" | "directory"): Promise<void> {
|
createFileOrFolder(path: string, type: "file" | "directory"): Promise<void> {
|
||||||
@@ -150,7 +173,7 @@ export class FSModule extends ServerModule {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadFile(path: string, raw?: boolean): Promise<any> {
|
downloadFile(path: string, raw: boolean = false, ignoreFailure: boolean = false): Promise<any> {
|
||||||
return this.retryWithAuth(async () => {
|
return this.retryWithAuth(async () => {
|
||||||
const encodedPath = encodeURIComponent(path);
|
const encodedPath = encodeURIComponent(path);
|
||||||
const fileData = await useServersFetch(`/download?path=${encodedPath}`, {
|
const fileData = await useServersFetch(`/download?path=${encodedPath}`, {
|
||||||
@@ -161,7 +184,7 @@ export class FSModule extends ServerModule {
|
|||||||
return raw ? fileData : await fileData.text();
|
return raw ? fileData : await fileData.text();
|
||||||
}
|
}
|
||||||
return fileData;
|
return fileData;
|
||||||
});
|
}, ignoreFailure);
|
||||||
}
|
}
|
||||||
|
|
||||||
extractFile(
|
extractFile(
|
||||||
|
|||||||
@@ -46,13 +46,18 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
|||||||
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined;
|
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const motd = await this.getMotd();
|
try {
|
||||||
if (motd === "A Minecraft Server") {
|
const motd = await this.getMotd();
|
||||||
await this.setMotd(
|
if (motd === "A Minecraft Server") {
|
||||||
`§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`,
|
await this.setMotd(
|
||||||
);
|
`§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
data.motd = motd;
|
||||||
|
} catch {
|
||||||
|
console.error("[Modrinth Servers] [General] Failed to fetch MOTD.");
|
||||||
|
data.motd = undefined;
|
||||||
}
|
}
|
||||||
data.motd = motd;
|
|
||||||
|
|
||||||
// Copy data to this module
|
// Copy data to this module
|
||||||
Object.assign(this, data);
|
Object.assign(this, data);
|
||||||
@@ -178,7 +183,7 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
|||||||
|
|
||||||
async getMotd(): Promise<string | undefined> {
|
async getMotd(): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
const props = await this.server.fs.downloadFile("/server.properties");
|
const props = await this.server.fs.downloadFile("/server.properties", false, true);
|
||||||
if (props) {
|
if (props) {
|
||||||
const lines = props.split("\n");
|
const lines = props.split("\n");
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
|||||||
@@ -42,6 +42,23 @@ export async function useServersFetch<T>(
|
|||||||
retry = method === "GET" ? 3 : 0,
|
retry = method === "GET" ? 3 : 0,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
const circuitBreakerKey = `${module || "default"}_${path}`;
|
||||||
|
const failureCount = useState<number>(`fetch_failures_${circuitBreakerKey}`, () => 0);
|
||||||
|
const lastFailureTime = useState<number>(`last_failure_${circuitBreakerKey}`, () => 0);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
|
||||||
|
const error = new ModrinthServersFetchError(
|
||||||
|
"[Modrinth Servers] Circuit breaker open - too many recent failures",
|
||||||
|
503,
|
||||||
|
);
|
||||||
|
throw new ModrinthServerError("Service temporarily unavailable", 503, error, module);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - lastFailureTime.value > 30000) {
|
||||||
|
failureCount.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
|
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
|
||||||
/\/$/,
|
/\/$/,
|
||||||
"",
|
"",
|
||||||
@@ -69,6 +86,7 @@ export async function useServersFetch<T>(
|
|||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"User-Agent": "Modrinth/1.0 (https://modrinth.com)",
|
"User-Agent": "Modrinth/1.0 (https://modrinth.com)",
|
||||||
|
"X-Archon-Request": "true",
|
||||||
Vary: "Accept, Origin",
|
Vary: "Accept, Origin",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,10 +112,12 @@ export async function useServersFetch<T>(
|
|||||||
const response = await $fetch<T>(fullUrl, {
|
const response = await $fetch<T>(fullUrl, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
|
body:
|
||||||
|
body && contentType === "application/json" ? JSON.stringify(body) : (body ?? undefined),
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
failureCount.value = 0;
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error as Error;
|
lastError = error as Error;
|
||||||
@@ -107,6 +127,11 @@ export async function useServersFetch<T>(
|
|||||||
const statusCode = error.response?.status;
|
const statusCode = error.response?.status;
|
||||||
const statusText = error.response?.statusText || "Unknown error";
|
const statusText = error.response?.statusText || "Unknown error";
|
||||||
|
|
||||||
|
if (statusCode && statusCode >= 500) {
|
||||||
|
failureCount.value++;
|
||||||
|
lastFailureTime.value = now;
|
||||||
|
}
|
||||||
|
|
||||||
let v1Error: V1ErrorInfo | undefined;
|
let v1Error: V1ErrorInfo | undefined;
|
||||||
if (error.data?.error && error.data?.description) {
|
if (error.data?.error && error.data?.description) {
|
||||||
v1Error = {
|
v1Error = {
|
||||||
@@ -134,9 +159,11 @@ export async function useServersFetch<T>(
|
|||||||
? errorMessages[statusCode]
|
? errorMessages[statusCode]
|
||||||
: `HTTP Error: ${statusCode || "unknown"} ${statusText}`;
|
: `HTTP Error: ${statusCode || "unknown"} ${statusText}`;
|
||||||
|
|
||||||
const isRetryable = statusCode ? [408, 429, 500, 502, 504].includes(statusCode) : true;
|
const isRetryable = statusCode ? [408, 429].includes(statusCode) : false;
|
||||||
|
const is5xxRetryable =
|
||||||
|
statusCode && statusCode >= 500 && statusCode < 600 && method === "GET" && attempts === 1;
|
||||||
|
|
||||||
if (!isRetryable || attempts >= maxAttempts) {
|
if (!(isRetryable || is5xxRetryable) || attempts >= maxAttempts) {
|
||||||
console.error("Fetch error:", error);
|
console.error("Fetch error:", error);
|
||||||
|
|
||||||
const fetchError = new ModrinthServersFetchError(
|
const fetchError = new ModrinthServersFetchError(
|
||||||
@@ -147,7 +174,8 @@ export async function useServersFetch<T>(
|
|||||||
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error);
|
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000);
|
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000;
|
||||||
|
const delay = Math.min(baseDelay * Math.pow(2, attempts - 1) + Math.random() * 1000, 15000);
|
||||||
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`);
|
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`);
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
12
apps/frontend/src/composables/util.ts
Normal file
12
apps/frontend/src/composables/util.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export const useNotificationRightwards = () => {
|
||||||
|
const isVisible = useState("moderation-checklist-notifications", () => false);
|
||||||
|
|
||||||
|
const setVisible = (visible: boolean) => {
|
||||||
|
isVisible.value = visible;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isVisible: readonly(isVisible),
|
||||||
|
setVisible,
|
||||||
|
};
|
||||||
|
};
|
||||||
236
apps/frontend/src/helpers/moderation.ts
Normal file
236
apps/frontend/src/helpers/moderation.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import type { ExtendedReport, OwnershipTarget } from "@modrinth/moderation";
|
||||||
|
import type {
|
||||||
|
Thread,
|
||||||
|
Version,
|
||||||
|
User,
|
||||||
|
Project,
|
||||||
|
TeamMember,
|
||||||
|
Organization,
|
||||||
|
Report,
|
||||||
|
} from "@modrinth/utils";
|
||||||
|
|
||||||
|
export const useModerationCache = () => ({
|
||||||
|
threads: useState<Map<string, Thread>>("moderation-report-cache-threads", () => new Map()),
|
||||||
|
users: useState<Map<string, User>>("moderation-report-cache-users", () => new Map()),
|
||||||
|
projects: useState<Map<string, Project>>("moderation-report-cache-projects", () => new Map()),
|
||||||
|
versions: useState<Map<string, Version>>("moderation-report-cache-versions", () => new Map()),
|
||||||
|
teams: useState<Map<string, TeamMember[]>>("moderation-report-cache-teams", () => new Map()),
|
||||||
|
orgs: useState<Map<string, Organization>>("moderation-report-cache-orgs", () => new Map()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: @AlexTMjugador - backend should do all of these functions.
|
||||||
|
export async function enrichReportBatch(reports: Report[]): Promise<ExtendedReport[]> {
|
||||||
|
if (reports.length === 0) return [];
|
||||||
|
|
||||||
|
const cache = useModerationCache();
|
||||||
|
|
||||||
|
const threadIDs = reports
|
||||||
|
.map((r) => r.thread_id)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((id) => !cache.threads.value.has(id));
|
||||||
|
const userIDs = [
|
||||||
|
...reports.filter((r) => r.item_type === "user").map((r) => r.item_id),
|
||||||
|
...reports.map((r) => r.reporter),
|
||||||
|
].filter((id) => !cache.users.value.has(id));
|
||||||
|
const versionIDs = reports
|
||||||
|
.filter((r) => r.item_type === "version")
|
||||||
|
.map((r) => r.item_id)
|
||||||
|
.filter((id) => !cache.versions.value.has(id));
|
||||||
|
const projectIDs = reports
|
||||||
|
.filter((r) => r.item_type === "project")
|
||||||
|
.map((r) => r.item_id)
|
||||||
|
.filter((id) => !cache.projects.value.has(id));
|
||||||
|
|
||||||
|
const [newThreads, newVersions, newUsers] = await Promise.all([
|
||||||
|
threadIDs.length > 0
|
||||||
|
? (fetchSegmented(threadIDs, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`) as Promise<
|
||||||
|
Thread[]
|
||||||
|
>)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
versionIDs.length > 0
|
||||||
|
? (fetchSegmented(versionIDs, (ids) => `versions?ids=${asEncodedJsonArray(ids)}`) as Promise<
|
||||||
|
Version[]
|
||||||
|
>)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
[...new Set(userIDs)].length > 0
|
||||||
|
? (fetchSegmented(
|
||||||
|
[...new Set(userIDs)],
|
||||||
|
(ids) => `users?ids=${asEncodedJsonArray(ids)}`,
|
||||||
|
) as Promise<User[]>)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
newThreads.forEach((t) => cache.threads.value.set(t.id, t));
|
||||||
|
newVersions.forEach((v) => cache.versions.value.set(v.id, v));
|
||||||
|
newUsers.forEach((u) => cache.users.value.set(u.id, u));
|
||||||
|
|
||||||
|
const allVersions = [...newVersions, ...Array.from(cache.versions.value.values())];
|
||||||
|
const fullProjectIds = new Set([
|
||||||
|
...projectIDs,
|
||||||
|
...allVersions
|
||||||
|
.filter((v) => versionIDs.includes(v.id))
|
||||||
|
.map((v) => v.project_id)
|
||||||
|
.filter(Boolean),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const uncachedProjectIds = Array.from(fullProjectIds).filter(
|
||||||
|
(id) => !cache.projects.value.has(id),
|
||||||
|
);
|
||||||
|
const newProjects =
|
||||||
|
uncachedProjectIds.length > 0
|
||||||
|
? ((await fetchSegmented(
|
||||||
|
uncachedProjectIds,
|
||||||
|
(ids) => `projects?ids=${asEncodedJsonArray(ids)}`,
|
||||||
|
)) as Project[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
newProjects.forEach((p) => cache.projects.value.set(p.id, p));
|
||||||
|
|
||||||
|
const allProjects = [...newProjects, ...Array.from(cache.projects.value.values())];
|
||||||
|
const teamIds = [...new Set(allProjects.map((p) => p.team).filter(Boolean))].filter(
|
||||||
|
(id) => !cache.teams.value.has(id || "invalid team id"),
|
||||||
|
);
|
||||||
|
const orgIds = [...new Set(allProjects.map((p) => p.organization).filter(Boolean))].filter(
|
||||||
|
(id) => !cache.orgs.value.has(id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [newTeams, newOrgs] = await Promise.all([
|
||||||
|
teamIds.length > 0
|
||||||
|
? (fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`) as Promise<
|
||||||
|
TeamMember[][]
|
||||||
|
>)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
orgIds.length > 0
|
||||||
|
? (fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
|
||||||
|
apiVersion: 3,
|
||||||
|
}) as Promise<Organization[]>)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
newTeams.forEach((team) => {
|
||||||
|
if (team.length > 0) cache.teams.value.set(team[0].team_id, team);
|
||||||
|
});
|
||||||
|
newOrgs.forEach((org) => cache.orgs.value.set(org.id, org));
|
||||||
|
|
||||||
|
return reports.map((report) => {
|
||||||
|
const thread = cache.threads.value.get(report.thread_id) || ({} as Thread);
|
||||||
|
const version =
|
||||||
|
report.item_type === "version" ? cache.versions.value.get(report.item_id) : undefined;
|
||||||
|
|
||||||
|
const project =
|
||||||
|
report.item_type === "project"
|
||||||
|
? cache.projects.value.get(report.item_id)
|
||||||
|
: report.item_type === "version" && version
|
||||||
|
? cache.projects.value.get(version.project_id)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let target: OwnershipTarget | undefined;
|
||||||
|
|
||||||
|
if (report.item_type === "user") {
|
||||||
|
const targetUser = cache.users.value.get(report.item_id);
|
||||||
|
if (targetUser) {
|
||||||
|
target = {
|
||||||
|
name: targetUser.username,
|
||||||
|
slug: targetUser.username,
|
||||||
|
avatar_url: targetUser.avatar_url,
|
||||||
|
type: "user",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (project) {
|
||||||
|
let owner: TeamMember | null = null;
|
||||||
|
let org: Organization | null = null;
|
||||||
|
|
||||||
|
if (project.team) {
|
||||||
|
const teamMembers = cache.teams.value.get(project.team);
|
||||||
|
if (teamMembers) {
|
||||||
|
owner = teamMembers.find((member) => member.role === "Owner") || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.organization) {
|
||||||
|
org = cache.orgs.value.get(project.organization) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org) {
|
||||||
|
target = {
|
||||||
|
name: org.name,
|
||||||
|
avatar_url: org.icon_url,
|
||||||
|
type: "organization",
|
||||||
|
slug: org.slug,
|
||||||
|
};
|
||||||
|
} else if (owner) {
|
||||||
|
target = {
|
||||||
|
name: owner.user.username,
|
||||||
|
avatar_url: owner.user.avatar_url,
|
||||||
|
type: "user",
|
||||||
|
slug: owner.user.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...report,
|
||||||
|
thread,
|
||||||
|
reporter_user: cache.users.value.get(report.reporter) || ({} as User),
|
||||||
|
project,
|
||||||
|
user: report.item_type === "user" ? cache.users.value.get(report.item_id) : undefined,
|
||||||
|
version,
|
||||||
|
target,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doesn't need to be in @modrinth/moderation because it is specific to the frontend.
|
||||||
|
export interface ModerationProject {
|
||||||
|
project: any;
|
||||||
|
owner: TeamMember | null;
|
||||||
|
org: Organization | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrichProjectBatch(projects: any[]): Promise<ModerationProject[]> {
|
||||||
|
const teamIds = [...new Set(projects.map((p) => p.team_id).filter(Boolean))];
|
||||||
|
const orgIds = [...new Set(projects.map((p) => p.organization).filter(Boolean))];
|
||||||
|
|
||||||
|
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
|
||||||
|
teamIds.length > 0
|
||||||
|
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
orgIds.length > 0
|
||||||
|
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
|
||||||
|
apiVersion: 3,
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cache = useModerationCache();
|
||||||
|
|
||||||
|
teamsData.forEach((team) => {
|
||||||
|
if (team.length > 0) cache.teams.value.set(team[0].team_id, team);
|
||||||
|
});
|
||||||
|
|
||||||
|
orgsData.forEach((org: Organization) => {
|
||||||
|
cache.orgs.value.set(org.id, org);
|
||||||
|
});
|
||||||
|
|
||||||
|
return projects.map((project) => {
|
||||||
|
let owner: TeamMember | null = null;
|
||||||
|
let org: Organization | null = null;
|
||||||
|
|
||||||
|
if (project.team_id) {
|
||||||
|
const teamMembers = cache.teams.value.get(project.team_id);
|
||||||
|
if (teamMembers) {
|
||||||
|
owner = teamMembers.find((member) => member.role === "Owner") || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.organization) {
|
||||||
|
org = cache.orgs.value.get(project.organization) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project,
|
||||||
|
owner,
|
||||||
|
org,
|
||||||
|
} as ModerationProject;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -295,7 +295,7 @@
|
|||||||
{
|
{
|
||||||
id: 'review-projects',
|
id: 'review-projects',
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
link: '/moderation/review',
|
link: '/moderation/',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'review-reports',
|
id: 'review-reports',
|
||||||
@@ -700,7 +700,6 @@ import {
|
|||||||
PackageOpenIcon,
|
PackageOpenIcon,
|
||||||
DiscordIcon,
|
DiscordIcon,
|
||||||
BlueskyIcon,
|
BlueskyIcon,
|
||||||
TumblrIcon,
|
|
||||||
TwitterIcon,
|
TwitterIcon,
|
||||||
MastodonIcon,
|
MastodonIcon,
|
||||||
GithubIcon,
|
GithubIcon,
|
||||||
@@ -982,23 +981,6 @@ const userMenuOptions = computed(() => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (
|
|
||||||
(auth.value && auth.value.user && auth.value.user.role === "moderator") ||
|
|
||||||
auth.value.user.role === "admin"
|
|
||||||
) {
|
|
||||||
options = [
|
|
||||||
...options,
|
|
||||||
{
|
|
||||||
divider: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "moderation",
|
|
||||||
color: "orange",
|
|
||||||
link: "/moderation/review",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
options = [
|
options = [
|
||||||
...options,
|
...options,
|
||||||
{
|
{
|
||||||
@@ -1185,13 +1167,6 @@ const socialLinks = [
|
|||||||
icon: MastodonIcon,
|
icon: MastodonIcon,
|
||||||
rel: "me",
|
rel: "me",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: formatMessage(
|
|
||||||
defineMessage({ id: "layout.footer.social.tumblr", defaultMessage: "Tumblr" }),
|
|
||||||
),
|
|
||||||
href: "https://tumblr.com/modrinth",
|
|
||||||
icon: TumblrIcon,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
|
label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
|
||||||
href: "https://x.com/modrinth",
|
href: "https://x.com/modrinth",
|
||||||
@@ -1346,6 +1321,15 @@ const footerLinks = [
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/legal/copyright",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({
|
||||||
|
id: "layout.footer.legal.copyright-policy",
|
||||||
|
defaultMessage: "Copyright Policy and DMCA",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -182,9 +182,6 @@
|
|||||||
"collection.button.unfollow-project": {
|
"collection.button.unfollow-project": {
|
||||||
"message": "Unfollow project"
|
"message": "Unfollow project"
|
||||||
},
|
},
|
||||||
"collection.button.upload-icon": {
|
|
||||||
"message": "Upload icon"
|
|
||||||
},
|
|
||||||
"collection.delete-modal.description": {
|
"collection.delete-modal.description": {
|
||||||
"message": "This will remove this collection forever. This action cannot be undone."
|
"message": "This will remove this collection forever. This action cannot be undone."
|
||||||
},
|
},
|
||||||
@@ -383,15 +380,15 @@
|
|||||||
"layout.footer.about": {
|
"layout.footer.about": {
|
||||||
"message": "About"
|
"message": "About"
|
||||||
},
|
},
|
||||||
"layout.footer.about.news": {
|
|
||||||
"message": "News"
|
|
||||||
},
|
|
||||||
"layout.footer.about.careers": {
|
"layout.footer.about.careers": {
|
||||||
"message": "Careers"
|
"message": "Careers"
|
||||||
},
|
},
|
||||||
"layout.footer.about.changelog": {
|
"layout.footer.about.changelog": {
|
||||||
"message": "Changelog"
|
"message": "Changelog"
|
||||||
},
|
},
|
||||||
|
"layout.footer.about.news": {
|
||||||
|
"message": "News"
|
||||||
|
},
|
||||||
"layout.footer.about.rewards-program": {
|
"layout.footer.about.rewards-program": {
|
||||||
"message": "Rewards Program"
|
"message": "Rewards Program"
|
||||||
},
|
},
|
||||||
@@ -404,6 +401,9 @@
|
|||||||
"layout.footer.legal-disclaimer": {
|
"layout.footer.legal-disclaimer": {
|
||||||
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
|
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
|
||||||
},
|
},
|
||||||
|
"layout.footer.legal.copyright-policy": {
|
||||||
|
"message": "Copyright Policy and DMCA"
|
||||||
|
},
|
||||||
"layout.footer.legal.privacy-policy": {
|
"layout.footer.legal.privacy-policy": {
|
||||||
"message": "Privacy Policy"
|
"message": "Privacy Policy"
|
||||||
},
|
},
|
||||||
@@ -458,9 +458,6 @@
|
|||||||
"layout.footer.social.mastodon": {
|
"layout.footer.social.mastodon": {
|
||||||
"message": "Mastodon"
|
"message": "Mastodon"
|
||||||
},
|
},
|
||||||
"layout.footer.social.tumblr": {
|
|
||||||
"message": "Tumblr"
|
|
||||||
},
|
|
||||||
"layout.footer.social.x": {
|
"layout.footer.social.x": {
|
||||||
"message": "X"
|
"message": "X"
|
||||||
},
|
},
|
||||||
@@ -479,6 +476,30 @@
|
|||||||
"layout.nav.search": {
|
"layout.nav.search": {
|
||||||
"message": "Search"
|
"message": "Search"
|
||||||
},
|
},
|
||||||
|
"moderation.filter.by": {
|
||||||
|
"message": "Filter by"
|
||||||
|
},
|
||||||
|
"moderation.moderate": {
|
||||||
|
"message": "Moderate"
|
||||||
|
},
|
||||||
|
"moderation.page.projects": {
|
||||||
|
"message": "Projects"
|
||||||
|
},
|
||||||
|
"moderation.page.reports": {
|
||||||
|
"message": "Reports"
|
||||||
|
},
|
||||||
|
"moderation.page.technicalReview": {
|
||||||
|
"message": "Technical Review"
|
||||||
|
},
|
||||||
|
"moderation.search.placeholder": {
|
||||||
|
"message": "Search..."
|
||||||
|
},
|
||||||
|
"moderation.sort.by": {
|
||||||
|
"message": "Sort by"
|
||||||
|
},
|
||||||
|
"moderation.technical.search.placeholder": {
|
||||||
|
"message": "Search tech reviews..."
|
||||||
|
},
|
||||||
"profile.button.billing": {
|
"profile.button.billing": {
|
||||||
"message": "Manage user billing"
|
"message": "Manage user billing"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,12 +29,11 @@
|
|||||||
class="settings-header__icon"
|
class="settings-header__icon"
|
||||||
/>
|
/>
|
||||||
<div class="settings-header__text">
|
<div class="settings-header__text">
|
||||||
<h1 class="wrap-as-needed">
|
<h1 class="wrap-as-needed">{{ project.title }}</h1>
|
||||||
{{ project.title }}
|
|
||||||
</h1>
|
|
||||||
<ProjectStatusBadge :status="project.status" />
|
<ProjectStatusBadge :status="project.status" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Project settings</h2>
|
<h2>Project settings</h2>
|
||||||
<NavStack>
|
<NavStack>
|
||||||
<NavStackItem
|
<NavStackItem
|
||||||
@@ -111,6 +110,7 @@
|
|||||||
</NavStack>
|
</NavStack>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="normal-page__content">
|
<div class="normal-page__content">
|
||||||
<ProjectMemberHeader
|
<ProjectMemberHeader
|
||||||
v-if="currentMember"
|
v-if="currentMember"
|
||||||
@@ -145,6 +145,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="experimental-styles-within">
|
<div v-else class="experimental-styles-within">
|
||||||
<NewModal ref="settingsModal">
|
<NewModal ref="settingsModal">
|
||||||
<template #title>
|
<template #title>
|
||||||
@@ -174,9 +175,11 @@
|
|||||||
<div
|
<div
|
||||||
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
|
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
|
||||||
>
|
>
|
||||||
@@ -219,8 +222,7 @@
|
|||||||
:href="`modrinth://mod/${project.slug}`"
|
:href="`modrinth://mod/${project.slug}`"
|
||||||
@click="() => installWithApp()"
|
@click="() => installWithApp()"
|
||||||
>
|
>
|
||||||
<ModrinthIcon aria-hidden="true" />
|
<ModrinthIcon aria-hidden="true" /> Install with Modrinth App
|
||||||
Install with Modrinth App
|
|
||||||
<ExternalIcon aria-hidden="true" />
|
<ExternalIcon aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@@ -240,6 +242,7 @@
|
|||||||
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-auto flex w-fit flex-col gap-2">
|
<div class="mx-auto flex w-fit flex-col gap-2">
|
||||||
<ButtonStyled v-if="project.game_versions.length === 1">
|
<ButtonStyled v-if="project.game_versions.length === 1">
|
||||||
<div class="disabled button-like">
|
<div class="disabled button-like">
|
||||||
@@ -327,8 +330,7 @@
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ gameVersion }}
|
{{ gameVersion }} <CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
||||||
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</ScrollablePanel>
|
</ScrollablePanel>
|
||||||
@@ -419,7 +421,6 @@
|
|||||||
</ScrollablePanel>
|
</ScrollablePanel>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AutomaticAccordion div class="flex flex-col gap-2">
|
<AutomaticAccordion div class="flex flex-col gap-2">
|
||||||
<VersionSummary
|
<VersionSummary
|
||||||
v-if="filteredRelease"
|
v-if="filteredRelease"
|
||||||
@@ -470,10 +471,14 @@
|
|||||||
class="new-page sidebar"
|
class="new-page sidebar"
|
||||||
:class="{
|
:class="{
|
||||||
'alt-layout': cosmetics.leftContentLayout,
|
'alt-layout': cosmetics.leftContentLayout,
|
||||||
'ultimate-sidebar':
|
'checklist-open':
|
||||||
showModerationChecklist &&
|
showModerationChecklist &&
|
||||||
!collapsedModerationChecklist &&
|
!collapsedModerationChecklist &&
|
||||||
!flags.alwaysShowChecklistAsPopup,
|
!flags.alwaysShowChecklistAsPopup,
|
||||||
|
'checklist-collapsed':
|
||||||
|
showModerationChecklist &&
|
||||||
|
collapsedModerationChecklist &&
|
||||||
|
!flags.alwaysShowChecklistAsPopup,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="normal-page__header relative my-4">
|
<div class="normal-page__header relative my-4">
|
||||||
@@ -485,11 +490,11 @@
|
|||||||
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
|
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
|
||||||
>
|
>
|
||||||
<button @click="(event) => downloadModal.show(event)">
|
<button @click="(event) => downloadModal.show(event)">
|
||||||
<DownloadIcon aria-hidden="true" />
|
<DownloadIcon aria-hidden="true" /> Download
|
||||||
Download
|
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="contents sm:hidden">
|
<div class="contents sm:hidden">
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
size="large"
|
size="large"
|
||||||
@@ -554,9 +559,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
||||||
Modrinth Servers is the easiest way to play with your friends without hassle!
|
Modrinth Servers is the easiest way to play with your friends without hassle!
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="m-0 text-wrap text-sm font-bold text-primary">
|
<p class="m-0 text-wrap text-sm font-bold text-primary">
|
||||||
Starting at $5<span class="text-xs"> / month</span>
|
Starting at $5<span class="text-xs"> / month</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -621,6 +628,7 @@
|
|||||||
{{ option.name }}
|
{{ option.name }}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="menu-text">
|
<div v-else class="menu-text">
|
||||||
<p class="popout-text">No collections found.</p>
|
<p class="popout-text">No collections found.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -628,8 +636,7 @@
|
|||||||
class="btn collection-button"
|
class="btn collection-button"
|
||||||
@click="(event) => $refs.modal_collection.show(event)"
|
@click="(event) => $refs.modal_collection.show(event)"
|
||||||
>
|
>
|
||||||
<PlusIcon aria-hidden="true" />
|
<PlusIcon aria-hidden="true" /> Create new collection
|
||||||
Create new collection
|
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</PopoutMenu>
|
</PopoutMenu>
|
||||||
@@ -682,7 +689,10 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'moderation-checklist',
|
id: 'moderation-checklist',
|
||||||
action: () => (showModerationChecklist = true),
|
action: () => {
|
||||||
|
moderationStore.setSingleProject(project.id);
|
||||||
|
showModerationChecklist = true;
|
||||||
|
},
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
hoverOnly: true,
|
hoverOnly: true,
|
||||||
shown:
|
shown:
|
||||||
@@ -712,25 +722,14 @@
|
|||||||
:dropdown-id="`${baseId}-more-options`"
|
:dropdown-id="`${baseId}-more-options`"
|
||||||
>
|
>
|
||||||
<MoreVerticalIcon aria-hidden="true" />
|
<MoreVerticalIcon aria-hidden="true" />
|
||||||
<template #analytics>
|
<template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template>
|
||||||
<ChartIcon aria-hidden="true" />
|
|
||||||
Analytics
|
|
||||||
</template>
|
|
||||||
<template #moderation-checklist>
|
<template #moderation-checklist>
|
||||||
<ScaleIcon aria-hidden="true" />
|
<ScaleIcon aria-hidden="true" /> Review project
|
||||||
Review project
|
|
||||||
</template>
|
|
||||||
<template #report>
|
|
||||||
<ReportIcon aria-hidden="true" />
|
|
||||||
Report
|
|
||||||
</template>
|
|
||||||
<template #copy-id>
|
|
||||||
<ClipboardCopyIcon aria-hidden="true" />
|
|
||||||
Copy ID
|
|
||||||
</template>
|
</template>
|
||||||
|
<template #report> <ReportIcon aria-hidden="true" /> Report </template>
|
||||||
|
<template #copy-id> <ClipboardCopyIcon aria-hidden="true" /> Copy ID </template>
|
||||||
<template #copy-permalink>
|
<template #copy-permalink>
|
||||||
<ClipboardCopyIcon aria-hidden="true" />
|
<ClipboardCopyIcon aria-hidden="true" /> Copy permanent link
|
||||||
Copy permanent link
|
|
||||||
</template>
|
</template>
|
||||||
</OverflowMenu>
|
</OverflowMenu>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@@ -756,6 +755,7 @@
|
|||||||
updates unless the author decides to unarchive the project.
|
updates unless the author decides to unarchive the project.
|
||||||
</MessageBanner>
|
</MessageBanner>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="normal-page__sidebar">
|
<div class="normal-page__sidebar">
|
||||||
<ProjectSidebarCompatibility
|
<ProjectSidebarCompatibility
|
||||||
:project="project"
|
:project="project"
|
||||||
@@ -785,6 +785,7 @@
|
|||||||
/>
|
/>
|
||||||
<div class="card flex-card experimental-styles-within">
|
<div class="card flex-card experimental-styles-within">
|
||||||
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
|
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
|
||||||
|
|
||||||
<div class="details-list">
|
<div class="details-list">
|
||||||
<div class="details-list__item">
|
<div class="details-list__item">
|
||||||
<BookTextIcon aria-hidden="true" />
|
<BookTextIcon aria-hidden="true" />
|
||||||
@@ -813,53 +814,48 @@
|
|||||||
<span v-else>{{ licenseIdDisplay }}</span>
|
<span v-else>{{ licenseIdDisplay }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="project.approved"
|
v-if="project.approved"
|
||||||
v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
|
v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
|
||||||
class="details-list__item"
|
class="details-list__item"
|
||||||
>
|
>
|
||||||
<CalendarIcon aria-hidden="true" />
|
<CalendarIcon aria-hidden="true" />
|
||||||
<div>
|
<div>{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}</div>
|
||||||
{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
|
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
|
||||||
class="details-list__item"
|
class="details-list__item"
|
||||||
>
|
>
|
||||||
<CalendarIcon aria-hidden="true" />
|
<CalendarIcon aria-hidden="true" />
|
||||||
<div>
|
<div>{{ formatMessage(detailsMessages.created, { date: createdDate }) }}</div>
|
||||||
{{ formatMessage(detailsMessages.created, { date: createdDate }) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="project.status === 'processing' && project.queued"
|
v-if="project.status === 'processing' && project.queued"
|
||||||
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
|
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
|
||||||
class="details-list__item"
|
class="details-list__item"
|
||||||
>
|
>
|
||||||
<ScaleIcon aria-hidden="true" />
|
<ScaleIcon aria-hidden="true" />
|
||||||
<div>
|
<div>{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}</div>
|
||||||
{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="versions.length > 0 && project.updated"
|
v-if="versions.length > 0 && project.updated"
|
||||||
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
|
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
|
||||||
class="details-list__item"
|
class="details-list__item"
|
||||||
>
|
>
|
||||||
<VersionIcon aria-hidden="true" />
|
<VersionIcon aria-hidden="true" />
|
||||||
<div>
|
<div>{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}</div>
|
||||||
{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="normal-page__content">
|
<div class="normal-page__content">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto"><NavTabs :links="navLinks" class="mb-4" /></div>
|
||||||
<NavTabs :links="navLinks" class="mb-4" />
|
|
||||||
</div>
|
|
||||||
<NuxtPage
|
<NuxtPage
|
||||||
v-model:project="project"
|
v-model:project="project"
|
||||||
v-model:versions="versions"
|
v-model:versions="versions"
|
||||||
@@ -877,20 +873,22 @@
|
|||||||
@delete-version="deleteVersion"
|
@delete-version="deleteVersion"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="normal-page__ultimate-sidebar">
|
|
||||||
<ModerationChecklist
|
|
||||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
|
||||||
:project="project"
|
|
||||||
:future-projects="futureProjects"
|
|
||||||
:reset-project="resetProject"
|
|
||||||
:collapsed="collapsedModerationChecklist"
|
|
||||||
@exit="showModerationChecklist = false"
|
|
||||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||||
|
class="moderation-checklist"
|
||||||
|
>
|
||||||
|
<ModerationChecklist
|
||||||
|
:project="project"
|
||||||
|
:collapsed="collapsedModerationChecklist"
|
||||||
|
@exit="showModerationChecklist = false"
|
||||||
|
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
BookmarkIcon,
|
BookmarkIcon,
|
||||||
@@ -942,24 +940,17 @@ import {
|
|||||||
useRelativeTime,
|
useRelativeTime,
|
||||||
} from "@modrinth/ui";
|
} from "@modrinth/ui";
|
||||||
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
||||||
import {
|
import { formatCategory, formatProjectType, renderString } from "@modrinth/utils";
|
||||||
formatCategory,
|
|
||||||
formatProjectType,
|
|
||||||
isRejected,
|
|
||||||
isStaff,
|
|
||||||
isUnderReview,
|
|
||||||
renderString,
|
|
||||||
} from "@modrinth/utils";
|
|
||||||
import { navigateTo } from "#app";
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { Tooltip } from "floating-vue";
|
import { Tooltip } from "floating-vue";
|
||||||
|
import { useLocalStorage } from "@vueuse/core";
|
||||||
|
import { navigateTo } from "#app";
|
||||||
import Accordion from "~/components/ui/Accordion.vue";
|
import Accordion from "~/components/ui/Accordion.vue";
|
||||||
// import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
// import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||||
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
||||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||||
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
||||||
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
|
|
||||||
import NavStack from "~/components/ui/NavStack.vue";
|
import NavStack from "~/components/ui/NavStack.vue";
|
||||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
||||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||||
@@ -967,10 +958,13 @@ import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
|
|||||||
import { userCollectProject } from "~/composables/user.js";
|
import { userCollectProject } from "~/composables/user.js";
|
||||||
import { reportProject } from "~/utils/report-helpers.ts";
|
import { reportProject } from "~/utils/report-helpers.ts";
|
||||||
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
||||||
|
import ModerationChecklist from "~/components/ui/moderation/checklist/ModerationChecklist.vue";
|
||||||
|
import { useModerationStore } from "~/store/moderation.ts";
|
||||||
|
|
||||||
const data = useNuxtApp();
|
const data = useNuxtApp();
|
||||||
const route = useNativeRoute();
|
const route = useNativeRoute();
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
|
const moderationStore = useModerationStore();
|
||||||
|
|
||||||
const auth = await useAuth();
|
const auth = await useAuth();
|
||||||
const user = await useUser();
|
const user = await useUser();
|
||||||
@@ -980,6 +974,7 @@ const flags = useFeatureFlags();
|
|||||||
const cosmetics = useCosmetics();
|
const cosmetics = useCosmetics();
|
||||||
|
|
||||||
const { formatMessage } = useVIntl();
|
const { formatMessage } = useVIntl();
|
||||||
|
const { setVisible } = useNotificationRightwards();
|
||||||
|
|
||||||
const settingsModal = ref();
|
const settingsModal = ref();
|
||||||
const downloadModal = ref();
|
const downloadModal = ref();
|
||||||
@@ -1551,12 +1546,22 @@ async function copyPermalink() {
|
|||||||
|
|
||||||
const collapsedChecklist = ref(false);
|
const collapsedChecklist = ref(false);
|
||||||
|
|
||||||
const showModerationChecklist = ref(false);
|
const showModerationChecklist = useLocalStorage(
|
||||||
const collapsedModerationChecklist = ref(false);
|
`show-moderation-checklist-${project.value.id}`,
|
||||||
const futureProjects = ref([]);
|
false,
|
||||||
|
);
|
||||||
|
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
showModerationChecklist,
|
||||||
|
(newValue) => {
|
||||||
|
setVisible(newValue);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
if (import.meta.client && history && history.state && history.state.showChecklist) {
|
if (import.meta.client && history && history.state && history.state.showChecklist) {
|
||||||
showModerationChecklist.value = true;
|
showModerationChecklist.value = true;
|
||||||
futureProjects.value = history.state.projects;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDownloadModal(event) {
|
function closeDownloadModal(event) {
|
||||||
@@ -1619,13 +1624,12 @@ const navLinks = computed(() => {
|
|||||||
{
|
{
|
||||||
label: formatMessage(messages.moderationTab),
|
label: formatMessage(messages.moderationTab),
|
||||||
href: `${projectUrl}/moderation`,
|
href: `${projectUrl}/moderation`,
|
||||||
shown:
|
shown: !!currentMember.value,
|
||||||
!!currentMember.value &&
|
|
||||||
(isRejected(project.value) || isUnderReview(project.value) || isStaff(auth.value.user)),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.settings-header {
|
.settings-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1781,4 +1785,16 @@ const navLinks = computed(() => {
|
|||||||
left: 18px;
|
left: 18px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.moderation-checklist {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 50;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -365,8 +365,10 @@ export default defineNuxtComponent({
|
|||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
this.expandedGalleryItem = null;
|
this.expandedGalleryItem = null;
|
||||||
} else if (e.key === "ArrowLeft") {
|
} else if (e.key === "ArrowLeft") {
|
||||||
|
e.stopPropagation();
|
||||||
this.previousImage();
|
this.previousImage();
|
||||||
} else if (e.key === "ArrowRight") {
|
} else if (e.key === "ArrowRight") {
|
||||||
|
e.stopPropagation();
|
||||||
this.nextImage();
|
this.nextImage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -705,9 +707,9 @@ export default defineNuxtComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gallery-body {
|
.gallery-body {
|
||||||
flex-grow: 1;
|
|
||||||
width: calc(100% - 2 * var(--spacing-card-md));
|
width: calc(100% - 2 * var(--spacing-card-md));
|
||||||
padding: var(--spacing-card-sm) var(--spacing-card-md);
|
padding: var(--spacing-card-sm) var(--spacing-card-md);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
.gallery-info {
|
.gallery-info {
|
||||||
h2 {
|
h2 {
|
||||||
|
|||||||
@@ -76,8 +76,15 @@
|
|||||||
<p>
|
<p>
|
||||||
This is a private conversation thread with the Modrinth moderators. They may message you
|
This is a private conversation thread with the Modrinth moderators. They may message you
|
||||||
with issues concerning this project. This thread is only checked when you submit your
|
with issues concerning this project. This thread is only checked when you submit your
|
||||||
project for review. For additional inquiries, contact
|
project for review. For additional inquiries, please go to the
|
||||||
<a href="https://support.modrinth.com">Modrinth Support</a>.
|
<a class="text-link" href="https://support.modrinth.com" target="_blank">
|
||||||
|
Modrinth Help Center
|
||||||
|
</a>
|
||||||
|
and click the green bubble to contact support.
|
||||||
|
</p>
|
||||||
|
<p v-if="isApproved(project)" class="warning">
|
||||||
|
<IssuesIcon /> The moderators do not actively monitor this chat. However, they may still see
|
||||||
|
messages here if there is a problem with your project.
|
||||||
</p>
|
</p>
|
||||||
<ConversationThread
|
<ConversationThread
|
||||||
v-if="thread"
|
v-if="thread"
|
||||||
|
|||||||
@@ -58,6 +58,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NewModal>
|
</NewModal>
|
||||||
|
<NewModal ref="modifyModal">
|
||||||
|
<template #title>
|
||||||
|
<span class="text-lg font-extrabold text-contrast">Modify charge</span>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="cancel" class="flex flex-col gap-1">
|
||||||
|
<span class="text-lg font-semibold text-contrast">
|
||||||
|
Cancel server
|
||||||
|
<span class="text-brand-red">*</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Whether or not the subscription should be cancelled. Submitting this as "true" will
|
||||||
|
cancel the subscription, while submitting it as "false" will force another charge
|
||||||
|
attempt to be made.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<Toggle id="cancel" v-model="cancel" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button :disabled="modifying" @click="modifyCharge">
|
||||||
|
<CheckIcon aria-hidden="true" />
|
||||||
|
Modify charge
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="modifyModal.hide()">
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NewModal>
|
||||||
<div class="page experimental-styles-within">
|
<div class="page experimental-styles-within">
|
||||||
<div
|
<div
|
||||||
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
|
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
|
||||||
@@ -150,9 +185,26 @@
|
|||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm text-secondary">
|
<span class="text-sm text-secondary">
|
||||||
|
<span
|
||||||
|
v-if="charge.status === 'cancelled' && $dayjs(charge.due).isBefore($dayjs())"
|
||||||
|
class="font-bold"
|
||||||
|
>
|
||||||
|
Ended:
|
||||||
|
</span>
|
||||||
|
<span v-else-if="charge.status === 'cancelled'" class="font-bold">Ends:</span>
|
||||||
|
<span v-else-if="charge.type === 'refund'" class="font-bold">Issued:</span>
|
||||||
|
<span v-else class="font-bold">Due:</span>
|
||||||
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
|
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
|
||||||
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
|
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="charge.last_attempt != null" class="text-sm text-secondary">
|
||||||
|
<span v-if="charge.status === 'failed'" class="font-bold">Last attempt:</span>
|
||||||
|
<span v-else class="font-bold">Charged:</span>
|
||||||
|
{{ dayjs(charge.last_attempt).format("MMMM D, YYYY [at] h:mma") }}
|
||||||
|
<span class="text-secondary"
|
||||||
|
>({{ formatRelativeTime(charge.last_attempt) }})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<div class="flex w-full items-center gap-1 text-xs text-secondary">
|
<div class="flex w-full items-center gap-1 text-xs text-secondary">
|
||||||
{{ charge.status }}
|
{{ charge.status }}
|
||||||
⋅
|
⋅
|
||||||
@@ -184,6 +236,12 @@
|
|||||||
Refund options
|
Refund options
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-else-if="charge.status === 'failed'" color="red" color-fill="text">
|
||||||
|
<button @click="showModifyModal(subscription)">
|
||||||
|
<CurrencyIcon />
|
||||||
|
Modify charge
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,7 +275,6 @@ import { products } from "~/generated/state.json";
|
|||||||
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
|
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const data = useNuxtApp();
|
|
||||||
const vintl = useVIntl();
|
const vintl = useVIntl();
|
||||||
|
|
||||||
const { formatMessage } = vintl;
|
const { formatMessage } = vintl;
|
||||||
@@ -287,6 +344,10 @@ const refundTypes = ref(["full", "partial", "none"]);
|
|||||||
const refundAmount = ref(0);
|
const refundAmount = ref(0);
|
||||||
const unprovision = ref(true);
|
const unprovision = ref(true);
|
||||||
|
|
||||||
|
const modifying = ref(false);
|
||||||
|
const modifyModal = ref();
|
||||||
|
const cancel = ref(false);
|
||||||
|
|
||||||
function showRefundModal(charge) {
|
function showRefundModal(charge) {
|
||||||
selectedCharge.value = charge;
|
selectedCharge.value = charge;
|
||||||
refundType.value = "full";
|
refundType.value = "full";
|
||||||
@@ -295,6 +356,12 @@ function showRefundModal(charge) {
|
|||||||
refundModal.value.show();
|
refundModal.value.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showModifyModal(charge) {
|
||||||
|
selectedCharge.value = charge;
|
||||||
|
cancel.value = false;
|
||||||
|
modifyModal.value.show();
|
||||||
|
}
|
||||||
|
|
||||||
async function refundCharge() {
|
async function refundCharge() {
|
||||||
refunding.value = true;
|
refunding.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -310,8 +377,7 @@ async function refundCharge() {
|
|||||||
await refreshCharges();
|
await refreshCharges();
|
||||||
refundModal.value.hide();
|
refundModal.value.hide();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
data.$notify({
|
addNotification({
|
||||||
group: "main",
|
|
||||||
title: "Error refunding",
|
title: "Error refunding",
|
||||||
text: err.data?.description ?? err,
|
text: err.data?.description ?? err,
|
||||||
type: "error",
|
type: "error",
|
||||||
@@ -320,6 +386,32 @@ async function refundCharge() {
|
|||||||
refunding.value = false;
|
refunding.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function modifyCharge() {
|
||||||
|
modifying.value = true;
|
||||||
|
try {
|
||||||
|
await useBaseFetch(`billing/subscription/${selectedCharge.value.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
cancelled: cancel.value,
|
||||||
|
}),
|
||||||
|
internal: true,
|
||||||
|
});
|
||||||
|
addNotification({
|
||||||
|
title: "Resubscription request submitted",
|
||||||
|
text: "If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
await refreshCharges();
|
||||||
|
} catch (err) {
|
||||||
|
addNotification({
|
||||||
|
title: "Error reattempting charge",
|
||||||
|
text: err.data?.description ?? err,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
modifying.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
const chargeStatuses = {
|
const chargeStatuses = {
|
||||||
open: {
|
open: {
|
||||||
color: "bg-blue",
|
color: "bg-blue",
|
||||||
|
|||||||
@@ -1421,7 +1421,8 @@ useSeoMeta({
|
|||||||
width: 25rem;
|
width: 25rem;
|
||||||
height: 25rem;
|
height: 25rem;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
background: radial-gradient(
|
background:
|
||||||
|
radial-gradient(
|
||||||
50% 50% at 50% 50%,
|
50% 50% at 50% 50%,
|
||||||
rgba(5, 206, 69, 0.19) 0%,
|
rgba(5, 206, 69, 0.19) 0%,
|
||||||
rgba(15, 19, 49, 0.25) 100%
|
rgba(15, 19, 49, 0.25) 100%
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div v-if="subtleLauncherRedirectUri">
|
||||||
<template v-if="flow">
|
<iframe
|
||||||
|
:src="subtleLauncherRedirectUri"
|
||||||
|
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<template v-if="flow && !subtleLauncherRedirectUri">
|
||||||
<label for="two-factor-code">
|
<label for="two-factor-code">
|
||||||
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
|
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
|
||||||
<span class="label__description">
|
<span class="label__description">
|
||||||
@@ -189,6 +195,7 @@ const auth = await useAuth();
|
|||||||
const route = useNativeRoute();
|
const route = useNativeRoute();
|
||||||
|
|
||||||
const redirectTarget = route.query.redirect || "";
|
const redirectTarget = route.query.redirect || "";
|
||||||
|
const subtleLauncherRedirectUri = ref();
|
||||||
|
|
||||||
if (route.query.code && !route.fullPath.includes("new_account=true")) {
|
if (route.query.code && !route.fullPath.includes("new_account=true")) {
|
||||||
await finishSignIn();
|
await finishSignIn();
|
||||||
@@ -262,7 +269,32 @@ async function begin2FASignIn() {
|
|||||||
|
|
||||||
async function finishSignIn(token) {
|
async function finishSignIn(token) {
|
||||||
if (route.query.launcher) {
|
if (route.query.launcher) {
|
||||||
await navigateTo(`https://launcher-files.modrinth.com/?code=${token}`, { external: true });
|
if (!token) {
|
||||||
|
token = auth.value.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usesLocalhostRedirectionScheme =
|
||||||
|
["4", "6"].includes(route.query.ipver) && Number(route.query.port) < 65536;
|
||||||
|
|
||||||
|
const redirectUrl = usesLocalhostRedirectionScheme
|
||||||
|
? `http://${route.query.ipver === "4" ? "127.0.0.1" : "[::1]"}:${route.query.port}/?code=${token}`
|
||||||
|
: `https://launcher-files.modrinth.com/?code=${token}`;
|
||||||
|
|
||||||
|
if (usesLocalhostRedirectionScheme) {
|
||||||
|
// When using this redirection scheme, the auth token is very visible in the URL to the user.
|
||||||
|
// While we could make it harder to find with a POST request, such is security by obscurity:
|
||||||
|
// the user and other applications would still be able to sniff the token in the request body.
|
||||||
|
// So, to make the UX a little better by not changing the displayed URL, while keeping the
|
||||||
|
// token hidden from very casual observation and keeping the protocol as close to OAuth's
|
||||||
|
// standard flows as possible, let's execute the redirect within an iframe that visually
|
||||||
|
// covers the entire page.
|
||||||
|
subtleLauncherRedirectUri.value = redirectUrl;
|
||||||
|
} else {
|
||||||
|
await navigateTo(redirectUrl, {
|
||||||
|
external: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ const username = ref("");
|
|||||||
const password = ref("");
|
const password = ref("");
|
||||||
const confirmPassword = ref("");
|
const confirmPassword = ref("");
|
||||||
const token = ref("");
|
const token = ref("");
|
||||||
const subscribe = ref(true);
|
const subscribe = ref(false);
|
||||||
|
|
||||||
async function createAccount() {
|
async function createAccount() {
|
||||||
startLoading();
|
startLoading();
|
||||||
@@ -247,16 +247,14 @@ async function createAccount() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (route.query.launcher) {
|
|
||||||
await navigateTo(`https://launcher-files.modrinth.com/?code=${res.session}`, {
|
|
||||||
external: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await useAuth(res.session);
|
await useAuth(res.session);
|
||||||
await useUser();
|
await useUser();
|
||||||
|
|
||||||
|
if (route.query.launcher) {
|
||||||
|
await navigateTo({ path: "/auth/sign-in", query: route.query });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (route.query.redirect) {
|
if (route.query.redirect) {
|
||||||
await navigateTo(route.query.redirect);
|
await navigateTo(route.query.redirect);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -40,7 +40,6 @@
|
|||||||
@change="showPreviewImage"
|
@change="showPreviewImage"
|
||||||
>
|
>
|
||||||
<UploadIcon aria-hidden="true" />
|
<UploadIcon aria-hidden="true" />
|
||||||
{{ formatMessage(messages.uploadIconButton) }}
|
|
||||||
</FileInput>
|
</FileInput>
|
||||||
<Button
|
<Button
|
||||||
v-if="!deletedIcon && (previewImage || collection.icon_url)"
|
v-if="!deletedIcon && (previewImage || collection.icon_url)"
|
||||||
@@ -479,10 +478,6 @@ const messages = defineMessages({
|
|||||||
id: "collection.label.updated-at",
|
id: "collection.label.updated-at",
|
||||||
defaultMessage: "Updated {ago}",
|
defaultMessage: "Updated {ago}",
|
||||||
},
|
},
|
||||||
uploadIconButton: {
|
|
||||||
id: "collection.button.upload-icon",
|
|
||||||
defaultMessage: "Upload icon",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = useNuxtApp();
|
const data = useNuxtApp();
|
||||||
|
|||||||
@@ -266,12 +266,12 @@ const getRangeOfMethod = (method) => {
|
|||||||
|
|
||||||
const maxWithdrawAmount = computed(() => {
|
const maxWithdrawAmount = computed(() => {
|
||||||
const interval = selectedMethod.value.interval;
|
const interval = selectedMethod.value.interval;
|
||||||
return interval?.standard ? interval.standard.max : interval?.fixed?.values.slice(-1)[0] ?? 0;
|
return interval?.standard ? interval.standard.max : (interval?.fixed?.values.slice(-1)[0] ?? 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
const minWithdrawAmount = computed(() => {
|
const minWithdrawAmount = computed(() => {
|
||||||
const interval = selectedMethod.value.interval;
|
const interval = selectedMethod.value.interval;
|
||||||
return interval?.standard ? interval.standard.min : interval?.fixed?.values?.[0] ?? fees.value;
|
return interval?.standard ? interval.standard.min : (interval?.fixed?.values?.[0] ?? fees.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const withdrawAccount = computed(() => {
|
const withdrawAccount = computed(() => {
|
||||||
|
|||||||
@@ -1,33 +1,84 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="normal-page">
|
<div
|
||||||
<div class="normal-page__sidebar">
|
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
|
||||||
<aside class="universal-card">
|
>
|
||||||
<h1>Moderation</h1>
|
<h1>Moderation</h1>
|
||||||
<NavStack>
|
<NavTabs :links="moderationLinks" class="mb-4 hidden sm:flex" />
|
||||||
<NavStackItem link="/moderation" label="Overview">
|
<div class="mb-4 sm:hidden">
|
||||||
<ModrinthIcon aria-hidden="true" />
|
<Chips
|
||||||
</NavStackItem>
|
v-model="selectedChip"
|
||||||
<NavStackItem link="/moderation/review" label="Review projects">
|
:items="mobileNavOptions"
|
||||||
<ScaleIcon aria-hidden="true" />
|
:never-empty="true"
|
||||||
</NavStackItem>
|
@change="navigateToPage"
|
||||||
<NavStackItem link="/moderation/reports" label="Reports">
|
/>
|
||||||
<ReportIcon aria-hidden="true" />
|
|
||||||
</NavStackItem>
|
|
||||||
</NavStack>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
<div class="normal-page__content">
|
|
||||||
<NuxtPage />
|
|
||||||
</div>
|
</div>
|
||||||
|
<NuxtPage />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ModrinthIcon, ScaleIcon, ReportIcon } from "@modrinth/assets";
|
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||||
import NavStack from "~/components/ui/NavStack.vue";
|
import { Chips } from "@modrinth/ui";
|
||||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
import NavTabs from "@/components/ui/NavTabs.vue";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: "auth",
|
middleware: "auth",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
projectsTitle: {
|
||||||
|
id: "moderation.page.projects",
|
||||||
|
defaultMessage: "Projects",
|
||||||
|
},
|
||||||
|
technicalReviewTitle: {
|
||||||
|
id: "moderation.page.technicalReview",
|
||||||
|
defaultMessage: "Technical Review",
|
||||||
|
},
|
||||||
|
reportsTitle: {
|
||||||
|
id: "moderation.page.reports",
|
||||||
|
defaultMessage: "Reports",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const moderationLinks = [
|
||||||
|
{ label: formatMessage(messages.projectsTitle), href: "/moderation" },
|
||||||
|
{ label: formatMessage(messages.technicalReviewTitle), href: "/moderation/technical-review" },
|
||||||
|
{ label: formatMessage(messages.reportsTitle), href: "/moderation/reports" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mobileNavOptions = [
|
||||||
|
formatMessage(messages.projectsTitle),
|
||||||
|
formatMessage(messages.technicalReviewTitle),
|
||||||
|
formatMessage(messages.reportsTitle),
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedChip = computed({
|
||||||
|
get() {
|
||||||
|
const path = route.path;
|
||||||
|
if (path === "/moderation/technical-review") {
|
||||||
|
return formatMessage(messages.technicalReviewTitle);
|
||||||
|
} else if (path.startsWith("/moderation/reports/")) {
|
||||||
|
return formatMessage(messages.reportsTitle);
|
||||||
|
} else {
|
||||||
|
return formatMessage(messages.projectsTitle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set(value: string) {
|
||||||
|
navigateToPage(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function navigateToPage(selectedOption: string) {
|
||||||
|
if (selectedOption === formatMessage(messages.technicalReviewTitle)) {
|
||||||
|
router.push("/moderation/technical-review");
|
||||||
|
} else if (selectedOption === formatMessage(messages.reportsTitle)) {
|
||||||
|
router.push("/moderation/reports");
|
||||||
|
} else {
|
||||||
|
router.push("/moderation");
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,42 +1,339 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex flex-col gap-3">
|
||||||
<section class="universal-card">
|
<div class="flex flex-col justify-between gap-3 lg:flex-row">
|
||||||
<h2>Statistics</h2>
|
<div class="iconified-input flex-1 lg:max-w-md">
|
||||||
<div class="grid-display">
|
<SearchIcon aria-hidden="true" class="text-lg" />
|
||||||
<div class="grid-display__item">
|
<input
|
||||||
<div class="label">Projects</div>
|
v-model="query"
|
||||||
<div class="value">
|
class="h-[40px]"
|
||||||
{{ formatNumber(stats.projects, false) }}
|
autocomplete="off"
|
||||||
</div>
|
spellcheck="false"
|
||||||
</div>
|
type="text"
|
||||||
<div class="grid-display__item">
|
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||||
<div class="label">Versions</div>
|
@input="goToPage(1)"
|
||||||
<div class="value">
|
/>
|
||||||
{{ formatNumber(stats.versions, false) }}
|
<Button v-if="query" class="r-btn" @click="() => (query = '')">
|
||||||
</div>
|
<XIcon />
|
||||||
</div>
|
</Button>
|
||||||
<div class="grid-display__item">
|
|
||||||
<div class="label">Files</div>
|
|
||||||
<div class="value">
|
|
||||||
{{ formatNumber(stats.files, false) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid-display__item">
|
|
||||||
<div class="label">Authors</div>
|
|
||||||
<div class="value">
|
|
||||||
{{ formatNumber(stats.authors, false) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
|
||||||
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
|
<ConfettiExplosion v-if="visible" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<DropdownSelect
|
||||||
|
v-slot="{ selected }"
|
||||||
|
v-model="currentFilterType"
|
||||||
|
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||||
|
:name="formatMessage(messages.filterBy)"
|
||||||
|
:options="filterTypes as unknown[]"
|
||||||
|
@change="goToPage(1)"
|
||||||
|
>
|
||||||
|
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||||
|
<FilterIcon class="size-4 flex-shrink-0" />
|
||||||
|
<span class="truncate">{{ selected }} ({{ filteredProjects.length }})</span>
|
||||||
|
</span>
|
||||||
|
</DropdownSelect>
|
||||||
|
|
||||||
|
<DropdownSelect
|
||||||
|
v-slot="{ selected }"
|
||||||
|
v-model="currentSortType"
|
||||||
|
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
||||||
|
:name="formatMessage(messages.sortBy)"
|
||||||
|
:options="sortTypes as unknown[]"
|
||||||
|
@change="goToPage(1)"
|
||||||
|
>
|
||||||
|
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||||
|
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
|
||||||
|
<SortDescIcon v-else class="size-4 flex-shrink-0" />
|
||||||
|
<span class="truncate">{{ selected }}</span>
|
||||||
|
</span>
|
||||||
|
</DropdownSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ButtonStyled color="orange" class="w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
|
||||||
|
@click="moderateAllInFilter()"
|
||||||
|
>
|
||||||
|
<ScaleIcon class="size-4 flex-shrink-0" />
|
||||||
|
<span class="hidden sm:inline">{{ formatMessage(messages.moderate) }}</span>
|
||||||
|
<span class="sm:hidden">Moderate</span>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
|
||||||
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
|
<ConfettiExplosion v-if="visible" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col gap-2">
|
||||||
|
<div v-if="paginatedProjects.length === 0" class="universal-card h-24 animate-pulse"></div>
|
||||||
|
<ModerationQueueCard
|
||||||
|
v-for="item in paginatedProjects"
|
||||||
|
v-else
|
||||||
|
:key="item.project.id"
|
||||||
|
:queue-entry="item"
|
||||||
|
:owner="item.owner"
|
||||||
|
:org="item.org"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||||
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { formatNumber } from "@modrinth/utils";
|
import { DropdownSelect, Button, ButtonStyled, Pagination } from "@modrinth/ui";
|
||||||
|
import {
|
||||||
|
XIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SortAscIcon,
|
||||||
|
SortDescIcon,
|
||||||
|
FilterIcon,
|
||||||
|
ScaleIcon,
|
||||||
|
} from "@modrinth/assets";
|
||||||
|
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||||
|
import { useLocalStorage } from "@vueuse/core";
|
||||||
|
import ConfettiExplosion from "vue-confetti-explosion";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import ModerationQueueCard from "~/components/ui/moderation/ModerationQueueCard.vue";
|
||||||
|
import { useModerationStore } from "~/store/moderation.ts";
|
||||||
|
import { enrichProjectBatch, type ModerationProject } from "~/helpers/moderation.ts";
|
||||||
|
|
||||||
useHead({
|
const { formatMessage } = useVIntl();
|
||||||
title: "Staff overview - Modrinth",
|
const moderationStore = useModerationStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
if (import.meta.client && history && history.state && history.state.confetti) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
history.state.confetti = false;
|
||||||
|
visible.value = true;
|
||||||
|
await nextTick();
|
||||||
|
setTimeout(() => {
|
||||||
|
visible.value = false;
|
||||||
|
}, 5000);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
searchPlaceholder: {
|
||||||
|
id: "moderation.search.placeholder",
|
||||||
|
defaultMessage: "Search...",
|
||||||
|
},
|
||||||
|
filterBy: {
|
||||||
|
id: "moderation.filter.by",
|
||||||
|
defaultMessage: "Filter by",
|
||||||
|
},
|
||||||
|
sortBy: {
|
||||||
|
id: "moderation.sort.by",
|
||||||
|
defaultMessage: "Sort by",
|
||||||
|
},
|
||||||
|
moderate: {
|
||||||
|
id: "moderation.moderate",
|
||||||
|
defaultMessage: "Moderate",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: stats } = await useAsyncData("statistics", () => useBaseFetch("statistics"));
|
const { data: allProjects } = await useLazyAsyncData("moderation-projects", async () => {
|
||||||
|
const startTime = performance.now();
|
||||||
|
let currentOffset = 0;
|
||||||
|
const PROJECT_ENDPOINT_COUNT = 350;
|
||||||
|
const allProjects: ModerationProject[] = [];
|
||||||
|
|
||||||
|
const enrichmentPromises: Promise<ModerationProject[]>[] = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const projects = (await useBaseFetch(
|
||||||
|
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
|
||||||
|
{ internal: true },
|
||||||
|
)) as any[];
|
||||||
|
|
||||||
|
if (projects.length === 0) break;
|
||||||
|
|
||||||
|
const enrichmentPromise = enrichProjectBatch(projects);
|
||||||
|
enrichmentPromises.push(enrichmentPromise);
|
||||||
|
|
||||||
|
currentOffset += projects.length;
|
||||||
|
|
||||||
|
if (enrichmentPromises.length >= 3) {
|
||||||
|
const completed = await Promise.all(enrichmentPromises.splice(0, 2));
|
||||||
|
allProjects.push(...completed.flat());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projects.length < PROJECT_ENDPOINT_COUNT) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingBatches = await Promise.all(enrichmentPromises);
|
||||||
|
allProjects.push(...remainingBatches.flat());
|
||||||
|
|
||||||
|
const endTime = performance.now();
|
||||||
|
const duration = endTime - startTime;
|
||||||
|
|
||||||
|
console.debug(
|
||||||
|
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return allProjects;
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = ref(route.query.q?.toString() || "");
|
||||||
|
|
||||||
|
watch(
|
||||||
|
query,
|
||||||
|
(newQuery) => {
|
||||||
|
const currentQuery = { ...route.query };
|
||||||
|
if (newQuery) {
|
||||||
|
currentQuery.q = newQuery;
|
||||||
|
} else {
|
||||||
|
delete currentQuery.q;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace({
|
||||||
|
path: route.path,
|
||||||
|
query: currentQuery,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ immediate: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.q,
|
||||||
|
(newQueryParam) => {
|
||||||
|
const newValue = newQueryParam?.toString() || "";
|
||||||
|
if (query.value !== newValue) {
|
||||||
|
query.value = newValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentFilterType = useLocalStorage("moderation-current-filter-type", () => "All projects");
|
||||||
|
const filterTypes: readonly string[] = readonly([
|
||||||
|
"All projects",
|
||||||
|
"Modpacks",
|
||||||
|
"Mods",
|
||||||
|
"Resource Packs",
|
||||||
|
"Data Packs",
|
||||||
|
"Plugins",
|
||||||
|
"Shaders",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentSortType = useLocalStorage("moderation-current-sort-type", () => "Oldest");
|
||||||
|
const sortTypes: readonly string[] = readonly(["Oldest", "Newest"]);
|
||||||
|
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const itemsPerPage = 15;
|
||||||
|
const totalPages = computed(() => Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage));
|
||||||
|
|
||||||
|
const fuse = computed(() => {
|
||||||
|
if (!allProjects.value || allProjects.value.length === 0) return null;
|
||||||
|
return new Fuse(allProjects.value, {
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
name: "project.title",
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "project.slug",
|
||||||
|
weight: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "project.description",
|
||||||
|
weight: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "project.project_type",
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
|
"owner.user.username",
|
||||||
|
"org.name",
|
||||||
|
"org.slug",
|
||||||
|
],
|
||||||
|
includeScore: true,
|
||||||
|
threshold: 0.4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchResults = computed(() => {
|
||||||
|
if (!query.value || !fuse.value) return null;
|
||||||
|
return fuse.value.search(query.value).map((result) => result.item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseFiltered = computed(() => {
|
||||||
|
if (!allProjects.value) return [];
|
||||||
|
return query.value && searchResults.value ? searchResults.value : [...allProjects.value];
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeFiltered = computed(() => {
|
||||||
|
if (currentFilterType.value === "All projects") return baseFiltered.value;
|
||||||
|
|
||||||
|
const filterMap: Record<string, string> = {
|
||||||
|
Modpacks: "modpack",
|
||||||
|
Mods: "mod",
|
||||||
|
"Resource Packs": "resourcepack",
|
||||||
|
"Data Packs": "datapack",
|
||||||
|
Plugins: "plugin",
|
||||||
|
Shaders: "shader",
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectType = filterMap[currentFilterType.value];
|
||||||
|
if (!projectType) return baseFiltered.value;
|
||||||
|
|
||||||
|
return baseFiltered.value.filter((queueItem) =>
|
||||||
|
queueItem.project.project_types.includes(projectType),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredProjects = computed(() => {
|
||||||
|
const filtered = [...typeFiltered.value];
|
||||||
|
|
||||||
|
if (currentSortType.value === "Oldest") {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.project.queued || a.project.published || 0).getTime();
|
||||||
|
const dateB = new Date(b.project.queued || b.project.published || 0).getTime();
|
||||||
|
return dateA - dateB;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.project.queued || a.project.published || 0).getTime();
|
||||||
|
const dateB = new Date(b.project.queued || b.project.published || 0).getTime();
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginatedProjects = computed(() => {
|
||||||
|
if (!filteredProjects.value) return [];
|
||||||
|
const start = (currentPage.value - 1) * itemsPerPage;
|
||||||
|
const end = start + itemsPerPage;
|
||||||
|
return filteredProjects.value.slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
|
function goToPage(page: number) {
|
||||||
|
currentPage.value = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moderateAllInFilter() {
|
||||||
|
moderationStore.setQueue(filteredProjects.value.map((queueItem) => queueItem.project.id));
|
||||||
|
navigateTo({
|
||||||
|
name: "type-id",
|
||||||
|
params: {
|
||||||
|
type: "project",
|
||||||
|
id: moderationStore.getCurrentProjectId(),
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
showChecklist: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ReportView
|
|
||||||
:auth="auth"
|
|
||||||
:report-id="route.params.id"
|
|
||||||
:breadcrumbs-stack="[{ href: '/moderation/reports', label: 'Reports' }]"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import ReportView from "~/components/ui/report/ReportView.vue";
|
|
||||||
|
|
||||||
const auth = await useAuth();
|
|
||||||
const route = useNativeRoute();
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: `Report ${route.params.id} - Modrinth`,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<section class="universal-card">
|
|
||||||
<h2>Reports</h2>
|
|
||||||
<ReportsList :auth="auth" moderation />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import ReportsList from "~/components/ui/report/ReportsList.vue";
|
|
||||||
|
|
||||||
const auth = await useAuth();
|
|
||||||
useHead({
|
|
||||||
title: "Reports - Modrinth",
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
28
apps/frontend/src/pages/moderation/reports/[id].vue
Normal file
28
apps/frontend/src/pages/moderation/reports/[id].vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Report } from "@modrinth/utils";
|
||||||
|
import { enrichReportBatch } from "~/helpers/moderation.ts";
|
||||||
|
import ModerationReportCard from "~/components/ui/moderation/ModerationReportCard.vue";
|
||||||
|
|
||||||
|
const { params } = useRoute();
|
||||||
|
const reportId = params.id as string;
|
||||||
|
|
||||||
|
const { data: report } = await useAsyncData(`moderation-report-${reportId}`, async () => {
|
||||||
|
try {
|
||||||
|
const report = (await useBaseFetch(`report/${reportId}`, { apiVersion: 3 })) as Report;
|
||||||
|
const enrichedReport = (await enrichReportBatch([report]))[0];
|
||||||
|
return enrichedReport;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching report:", error);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: "Report not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<ModerationReportCard v-if="report" :report="report" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
290
apps/frontend/src/pages/moderation/reports/index.vue
Normal file
290
apps/frontend/src/pages/moderation/reports/index.vue
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col justify-between gap-3 lg:flex-row">
|
||||||
|
<div class="iconified-input flex-1 lg:max-w-md">
|
||||||
|
<SearchIcon aria-hidden="true" class="text-lg" />
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
class="h-[40px]"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
type="text"
|
||||||
|
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||||
|
@input="goToPage(1)"
|
||||||
|
/>
|
||||||
|
<Button v-if="query" class="r-btn" @click="() => (query = '')">
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
|
||||||
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
|
||||||
|
<DropdownSelect
|
||||||
|
v-slot="{ selected }"
|
||||||
|
v-model="currentFilterType"
|
||||||
|
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||||
|
:name="formatMessage(messages.filterBy)"
|
||||||
|
:options="filterTypes as unknown[]"
|
||||||
|
@change="goToPage(1)"
|
||||||
|
>
|
||||||
|
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||||
|
<FilterIcon class="size-4 flex-shrink-0" />
|
||||||
|
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
|
||||||
|
</span>
|
||||||
|
</DropdownSelect>
|
||||||
|
|
||||||
|
<DropdownSelect
|
||||||
|
v-slot="{ selected }"
|
||||||
|
v-model="currentSortType"
|
||||||
|
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
||||||
|
:name="formatMessage(messages.sortBy)"
|
||||||
|
:options="sortTypes as unknown[]"
|
||||||
|
@change="goToPage(1)"
|
||||||
|
>
|
||||||
|
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||||
|
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
|
||||||
|
<SortDescIcon v-else class="size-4 flex-shrink-0" />
|
||||||
|
<span class="truncate">{{ selected }}</span>
|
||||||
|
</span>
|
||||||
|
</DropdownSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
|
||||||
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col gap-2">
|
||||||
|
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
|
||||||
|
<ReportCard v-for="report in paginatedReports" v-else :key="report.id" :report="report" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||||
|
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DropdownSelect, Button, Pagination } from "@modrinth/ui";
|
||||||
|
import { XIcon, SearchIcon, SortAscIcon, SortDescIcon, FilterIcon } from "@modrinth/assets";
|
||||||
|
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||||
|
import { useLocalStorage } from "@vueuse/core";
|
||||||
|
import type { Report } from "@modrinth/utils";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import type { ExtendedReport } from "@modrinth/moderation";
|
||||||
|
import ReportCard from "~/components/ui/moderation/ModerationReportCard.vue";
|
||||||
|
import { enrichReportBatch } from "~/helpers/moderation.ts";
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
searchPlaceholder: {
|
||||||
|
id: "moderation.search.placeholder",
|
||||||
|
defaultMessage: "Search...",
|
||||||
|
},
|
||||||
|
filterBy: {
|
||||||
|
id: "moderation.filter.by",
|
||||||
|
defaultMessage: "Filter by",
|
||||||
|
},
|
||||||
|
sortBy: {
|
||||||
|
id: "moderation.sort.by",
|
||||||
|
defaultMessage: "Sort by",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: allReports } = await useLazyAsyncData("new-moderation-reports", async () => {
|
||||||
|
const startTime = performance.now();
|
||||||
|
let currentOffset = 0;
|
||||||
|
const REPORT_ENDPOINT_COUNT = 350;
|
||||||
|
const allReports: ExtendedReport[] = [];
|
||||||
|
|
||||||
|
const enrichmentPromises: Promise<ExtendedReport[]>[] = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const reports = (await useBaseFetch(
|
||||||
|
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}`,
|
||||||
|
{ apiVersion: 3 },
|
||||||
|
)) as Report[];
|
||||||
|
|
||||||
|
if (reports.length === 0) break;
|
||||||
|
|
||||||
|
const enrichmentPromise = enrichReportBatch(reports);
|
||||||
|
enrichmentPromises.push(enrichmentPromise);
|
||||||
|
|
||||||
|
currentOffset += reports.length;
|
||||||
|
|
||||||
|
if (enrichmentPromises.length >= 3) {
|
||||||
|
const completed = await Promise.all(enrichmentPromises.splice(0, 2));
|
||||||
|
allReports.push(...completed.flat());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reports.length < REPORT_ENDPOINT_COUNT) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingBatches = await Promise.all(enrichmentPromises);
|
||||||
|
allReports.push(...remainingBatches.flat());
|
||||||
|
|
||||||
|
const endTime = performance.now();
|
||||||
|
const duration = endTime - startTime;
|
||||||
|
|
||||||
|
console.debug(
|
||||||
|
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return allReports;
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = ref(route.query.q?.toString() || "");
|
||||||
|
|
||||||
|
watch(
|
||||||
|
query,
|
||||||
|
(newQuery) => {
|
||||||
|
const currentQuery = { ...route.query };
|
||||||
|
if (newQuery) {
|
||||||
|
currentQuery.q = newQuery;
|
||||||
|
} else {
|
||||||
|
delete currentQuery.q;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace({
|
||||||
|
path: route.path,
|
||||||
|
query: currentQuery,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ immediate: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.q,
|
||||||
|
(newQueryParam) => {
|
||||||
|
const newValue = newQueryParam?.toString() || "";
|
||||||
|
if (query.value !== newValue) {
|
||||||
|
query.value = newValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentFilterType = useLocalStorage("moderation-reports-filter-type", () => "All");
|
||||||
|
const filterTypes: readonly string[] = readonly(["All", "Unread", "Read"]);
|
||||||
|
|
||||||
|
const currentSortType = useLocalStorage("moderation-reports-sort-type", () => "Oldest");
|
||||||
|
const sortTypes: readonly string[] = readonly(["Oldest", "Newest"]);
|
||||||
|
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const itemsPerPage = 15;
|
||||||
|
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage));
|
||||||
|
|
||||||
|
const fuse = computed(() => {
|
||||||
|
if (!allReports.value || allReports.value.length === 0) return null;
|
||||||
|
return new Fuse(allReports.value, {
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "body",
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "report_type",
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "item_id",
|
||||||
|
weight: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reporter_user.username",
|
||||||
|
weight: 2,
|
||||||
|
},
|
||||||
|
"project.name",
|
||||||
|
"project.slug",
|
||||||
|
"user.username",
|
||||||
|
"version.name",
|
||||||
|
"target.name",
|
||||||
|
"target.slug",
|
||||||
|
],
|
||||||
|
includeScore: true,
|
||||||
|
threshold: 0.4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberRoleMap = computed(() => {
|
||||||
|
if (!allReports.value?.length) return new Map();
|
||||||
|
|
||||||
|
const map = new Map();
|
||||||
|
for (const report of allReports.value) {
|
||||||
|
if (report.thread?.members?.length) {
|
||||||
|
const roleMap = new Map();
|
||||||
|
for (const member of report.thread.members) {
|
||||||
|
roleMap.set(member.id, member.role);
|
||||||
|
}
|
||||||
|
map.set(report.id, roleMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchResults = computed(() => {
|
||||||
|
if (!query.value || !fuse.value) return null;
|
||||||
|
return fuse.value.search(query.value).map((result) => result.item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseFiltered = computed(() => {
|
||||||
|
if (!allReports.value) return [];
|
||||||
|
return query.value && searchResults.value ? searchResults.value : [...allReports.value];
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeFiltered = computed(() => {
|
||||||
|
if (currentFilterType.value === "All") return baseFiltered.value;
|
||||||
|
|
||||||
|
return baseFiltered.value.filter((report) => {
|
||||||
|
const messages = report.thread?.messages || [];
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return currentFilterType.value === "Unread";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
if (!lastMessage.author_id) return false;
|
||||||
|
|
||||||
|
const roleMap = memberRoleMap.value.get(report.id);
|
||||||
|
if (!roleMap) return false;
|
||||||
|
|
||||||
|
const authorRole = roleMap.get(lastMessage.author_id);
|
||||||
|
const isModeratorMessage = authorRole === "moderator" || authorRole === "admin";
|
||||||
|
|
||||||
|
return currentFilterType.value === "Read" ? isModeratorMessage : !isModeratorMessage;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredReports = computed(() => {
|
||||||
|
const filtered = [...typeFiltered.value];
|
||||||
|
|
||||||
|
if (currentSortType.value === "Oldest") {
|
||||||
|
filtered.sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime());
|
||||||
|
} else {
|
||||||
|
filtered.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginatedReports = computed(() => {
|
||||||
|
if (!filteredReports.value) return [];
|
||||||
|
const start = (currentPage.value - 1) * itemsPerPage;
|
||||||
|
const end = start + itemsPerPage;
|
||||||
|
return filteredReports.value.slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
|
function goToPage(page: number) {
|
||||||
|
currentPage.value = page;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="universal-card">
|
|
||||||
<h2>Review projects</h2>
|
|
||||||
<div class="input-group">
|
|
||||||
<Chips
|
|
||||||
v-model="projectType"
|
|
||||||
:items="projectTypes"
|
|
||||||
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x) + 's')"
|
|
||||||
/>
|
|
||||||
<button v-if="oldestFirst" class="iconified-button push-right" @click="oldestFirst = false">
|
|
||||||
<SortDescIcon />
|
|
||||||
Sorting by oldest
|
|
||||||
</button>
|
|
||||||
<button v-else class="iconified-button push-right" @click="oldestFirst = true">
|
|
||||||
<SortAscIcon />
|
|
||||||
Sorting by newest
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-highlight"
|
|
||||||
:disabled="projectsFiltered.length === 0"
|
|
||||||
@click="goToProjects()"
|
|
||||||
>
|
|
||||||
<ScaleIcon />
|
|
||||||
Start moderating
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="projectType !== 'all'" class="project-count">
|
|
||||||
Showing {{ projectsFiltered.length }} {{ projectTypePlural }} of {{ projects.length }} total
|
|
||||||
projects in the queue.
|
|
||||||
</p>
|
|
||||||
<p v-else class="project-count">There are {{ projects.length }} projects in the queue.</p>
|
|
||||||
<p v-if="projectsOver24Hours.length > 0" class="warning project-count">
|
|
||||||
<IssuesIcon />
|
|
||||||
{{ projectsOver24Hours.length }} {{ projectTypePlural }}
|
|
||||||
have been in the queue for over 24 hours.
|
|
||||||
</p>
|
|
||||||
<p v-if="projectsOver48Hours.length > 0" class="danger project-count">
|
|
||||||
<IssuesIcon />
|
|
||||||
{{ projectsOver48Hours.length }} {{ projectTypePlural }}
|
|
||||||
have been in the queue for over 48 hours.
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
v-for="project in projectsFiltered.sort((a, b) => {
|
|
||||||
if (oldestFirst) {
|
|
||||||
return b.age - a.age;
|
|
||||||
} else {
|
|
||||||
return a.age - b.age;
|
|
||||||
}
|
|
||||||
})"
|
|
||||||
:key="`project-${project.id}`"
|
|
||||||
class="universal-card recessed project"
|
|
||||||
>
|
|
||||||
<div class="project-title">
|
|
||||||
<div class="mobile-row">
|
|
||||||
<nuxt-link :to="`/project/${project.id}`" class="iconified-stacked-link">
|
|
||||||
<Avatar :src="project.icon_url" size="xs" no-shadow raised />
|
|
||||||
<span class="stacked">
|
|
||||||
<span class="title">{{ project.name }}</span>
|
|
||||||
<span>{{ formatProjectType(project.inferred_project_type) }}</span>
|
|
||||||
</span>
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
<div class="mobile-row">
|
|
||||||
by
|
|
||||||
<nuxt-link
|
|
||||||
v-if="project.owner"
|
|
||||||
:to="`/user/${project.owner.user.id}`"
|
|
||||||
class="iconified-link"
|
|
||||||
>
|
|
||||||
<Avatar :src="project.owner.user.avatar_url" circle size="xxs" raised />
|
|
||||||
<span>{{ project.owner.user.username }}</span>
|
|
||||||
</nuxt-link>
|
|
||||||
<nuxt-link
|
|
||||||
v-else-if="project.org"
|
|
||||||
:to="`/organization/${project.org.id}`"
|
|
||||||
class="iconified-link"
|
|
||||||
>
|
|
||||||
<Avatar :src="project.org.icon_url" circle size="xxs" raised />
|
|
||||||
<span>{{ project.org.name }}</span>
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
<div class="mobile-row">
|
|
||||||
is requesting to be
|
|
||||||
<ProjectStatusBadge
|
|
||||||
:status="project.requested_status ? project.requested_status : 'approved'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-group">
|
|
||||||
<nuxt-link :to="`/project/${project.id}`" class="iconified-button raised-button">
|
|
||||||
<EyeIcon />
|
|
||||||
View project
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
<span v-if="project.queued" :class="`submitter-info ${project.age_warning}`">
|
|
||||||
<IssuesIcon v-if="project.age_warning" />
|
|
||||||
Submitted
|
|
||||||
<span v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')">{{
|
|
||||||
formatRelativeTime(project.queued)
|
|
||||||
}}</span>
|
|
||||||
</span>
|
|
||||||
<span v-else class="submitter-info"><UnknownIcon /> Unknown queue date</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { Avatar, ProjectStatusBadge, Chips, useRelativeTime } from "@modrinth/ui";
|
|
||||||
import {
|
|
||||||
UnknownIcon,
|
|
||||||
EyeIcon,
|
|
||||||
SortAscIcon,
|
|
||||||
SortDescIcon,
|
|
||||||
IssuesIcon,
|
|
||||||
ScaleIcon,
|
|
||||||
} from "@modrinth/assets";
|
|
||||||
import { formatProjectType } from "@modrinth/utils";
|
|
||||||
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: "Review projects - Modrinth",
|
|
||||||
});
|
|
||||||
|
|
||||||
const app = useNuxtApp();
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const now = app.$dayjs();
|
|
||||||
const TIME_24H = 86400000;
|
|
||||||
const TIME_48H = TIME_24H * 2;
|
|
||||||
|
|
||||||
const formatRelativeTime = useRelativeTime();
|
|
||||||
|
|
||||||
const { data: projects } = await useAsyncData("moderation/projects?count=1000", () =>
|
|
||||||
useBaseFetch("moderation/projects?count=1000", { internal: true }),
|
|
||||||
);
|
|
||||||
const members = ref([]);
|
|
||||||
const projectType = ref("all");
|
|
||||||
const oldestFirst = ref(true);
|
|
||||||
|
|
||||||
const projectsFiltered = computed(() =>
|
|
||||||
projects.value.filter(
|
|
||||||
(x) =>
|
|
||||||
projectType.value === "all" ||
|
|
||||||
app.$getProjectTypeForUrl(x.project_types[0], x.loaders) === projectType.value,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectsOver24Hours = computed(() =>
|
|
||||||
projectsFiltered.value.filter((project) => project.age >= TIME_24H && project.age < TIME_48H),
|
|
||||||
);
|
|
||||||
const projectsOver48Hours = computed(() =>
|
|
||||||
projectsFiltered.value.filter((project) => project.age >= TIME_48H),
|
|
||||||
);
|
|
||||||
const projectTypePlural = computed(() =>
|
|
||||||
projectType.value === "all"
|
|
||||||
? "projects"
|
|
||||||
: (formatProjectType(projectType.value) + "s").toLowerCase(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectTypes = computed(() => {
|
|
||||||
const set = new Set();
|
|
||||||
set.add("all");
|
|
||||||
|
|
||||||
if (projects.value) {
|
|
||||||
for (const project of projects.value) {
|
|
||||||
set.add(project.inferred_project_type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...set];
|
|
||||||
});
|
|
||||||
|
|
||||||
if (projects.value) {
|
|
||||||
const teamIds = projects.value.map((x) => x.team_id);
|
|
||||||
const orgIds = projects.value.filter((x) => x.organization).map((x) => x.organization);
|
|
||||||
|
|
||||||
const [{ data: teams }, { data: orgs }] = await Promise.all([
|
|
||||||
useAsyncData(`teams?ids=${asEncodedJsonArray(teamIds)}`, () =>
|
|
||||||
fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`),
|
|
||||||
),
|
|
||||||
useAsyncData(`organizations?ids=${asEncodedJsonArray(orgIds)}`, () =>
|
|
||||||
fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
|
|
||||||
apiVersion: 3,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (teams.value) {
|
|
||||||
members.value = teams.value;
|
|
||||||
|
|
||||||
projects.value = projects.value.map((project) => {
|
|
||||||
project.owner = members.value
|
|
||||||
? members.value.flat().find((x) => x.team_id === project.team_id && x.role === "Owner")
|
|
||||||
: null;
|
|
||||||
project.org = orgs.value ? orgs.value.find((x) => x.id === project.organization) : null;
|
|
||||||
project.age = project.queued ? now - app.$dayjs(project.queued) : Number.MAX_VALUE;
|
|
||||||
project.age_warning = "";
|
|
||||||
if (project.age > TIME_24H * 2) {
|
|
||||||
project.age_warning = "danger";
|
|
||||||
} else if (project.age > TIME_24H) {
|
|
||||||
project.age_warning = "warning";
|
|
||||||
}
|
|
||||||
project.inferred_project_type = app.$getProjectTypeForUrl(
|
|
||||||
project.project_types[0],
|
|
||||||
project.loaders,
|
|
||||||
);
|
|
||||||
return project;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function goToProjects() {
|
|
||||||
const project = projectsFiltered.value[0];
|
|
||||||
await router.push({
|
|
||||||
name: "type-id",
|
|
||||||
params: {
|
|
||||||
type: project.project_types[0],
|
|
||||||
id: project.slug ? project.slug : project.id,
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
showChecklist: true,
|
|
||||||
projects: projectsFiltered.value.slice(1).map((x) => (x.slug ? x.slug : x.id)),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.project {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-card-sm);
|
|
||||||
@media screen and (min-width: 650px) {
|
|
||||||
display: grid;
|
|
||||||
grid-template: "title action" "date action";
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.submitter-info {
|
|
||||||
margin: 0;
|
|
||||||
grid-area: date;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
|
||||||
color: var(--color-orange);
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger {
|
|
||||||
color: var(--color-red);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-count {
|
|
||||||
margin-block: var(--spacing-card-md);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group {
|
|
||||||
grid-area: action;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-title {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-card-xs);
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.mobile-row {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 800px) {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
|
|
||||||
.mobile-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: var(--spacing-card-xs);
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.avatar) {
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&.size-xs {
|
|
||||||
margin-right: var(--spacing-card-xs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user