mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ac3122b31 | ||
|
|
6b5f8a41e7 | ||
|
|
8b39ba491a | ||
|
|
c74460fffa | ||
|
|
5000c4067b | ||
|
|
af33950bbe | ||
|
|
075331b26c | ||
|
|
f31b74f7fd | ||
|
|
bcc36362be | ||
|
|
632b27dc21 | ||
|
|
cf6f3736eb | ||
|
|
aaaef8f39e | ||
|
|
3f8dd1a79c | ||
|
|
363f47f269 | ||
|
|
a0f23a2bca | ||
|
|
08e316a2b2 | ||
|
|
9aaf5fb87e | ||
|
|
bcca66b12c | ||
|
|
ccb24ce8eb | ||
|
|
5dd6c804d0 | ||
|
|
ab886a5ea8 | ||
|
|
03b0eba695 | ||
|
|
707ff2146b | ||
|
|
8d80433c2c | ||
|
|
a547f7a9b0 | ||
|
|
f78fbe3215 | ||
|
|
f375913c62 | ||
|
|
a4015d9df3 | ||
|
|
977de0e18a | ||
|
|
c379e4b173 | ||
|
|
eeed4e572d | ||
|
|
79502a19d6 | ||
|
|
3dbfd69bdd | ||
|
|
19393a38bb | ||
|
|
859d7f57cf | ||
|
|
24bec6baba | ||
|
|
63d8f70e20 | ||
|
|
8a30b7978d | ||
|
|
4a9f0b8a0e | ||
|
|
0e17427a58 | ||
|
|
ad3b5aec69 | ||
|
|
4b17eb5d35 | ||
|
|
6a70acef25 | ||
|
|
e58456eed4 | ||
|
|
12940fc207 | ||
|
|
7796273529 | ||
|
|
752f68124c | ||
|
|
699a049c69 | ||
|
|
fa7d1d7942 | ||
|
|
d1ffed564d | ||
|
|
e719ae2f42 | ||
|
|
5db5bf4c4c | ||
|
|
b23d3e674f | ||
|
|
75e3994c6e | ||
|
|
71e28e1ea5 | ||
|
|
6dbd1e5236 | ||
|
|
77afdb1cc4 | ||
|
|
7fa442fb28 | ||
|
|
2535156dac | ||
|
|
8972c9a198 | ||
|
|
03ed64c99f | ||
|
|
4cd8ccd319 | ||
|
|
ea594ec27c | ||
|
|
2a61916d1e | ||
|
|
e66b131a5d | ||
|
|
0c66fa3f12 | ||
|
|
aec49cff7c | ||
|
|
c88bdda3e6 |
@@ -1,6 +1,7 @@
|
||||
name: 🎮 Modrinth App bug
|
||||
description: Report an issue in the Modrinth Launcher.
|
||||
labels: [bug, app]
|
||||
name: 🎮 Bug with Modrinth App
|
||||
description: For issues with Modrinth App.
|
||||
labels: [app]
|
||||
type: 'bug'
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: 🌐 Website bug (modrinth.com)
|
||||
description: Report an issue on the Modrinth website.
|
||||
labels: [bug, web]
|
||||
name: 🌐 Bug with Modrinth.com
|
||||
description: For issues with the Modrinth website.
|
||||
labels: [website]
|
||||
type: 'bug'
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
name: 👥 Bug with Modrinth Servers
|
||||
description: For issues with a Modrinth Servers product.
|
||||
labels: [servers]
|
||||
type: 'bug'
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Please confirm the following.
|
||||
options:
|
||||
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems
|
||||
required: true
|
||||
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: issue-location
|
||||
attributes:
|
||||
label: Is this an issue in the control panel or with the Minecraft server itself?
|
||||
options:
|
||||
- Control panel (on Modrinth.com)
|
||||
- Minecraft server
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: What browsers are you seeing the problem on? (if a panel issue)
|
||||
multiple: true
|
||||
options:
|
||||
- N/A
|
||||
- Chrome (including Arc, Brave, Opera, Vivaldi)
|
||||
- Microsoft Edge
|
||||
- Firefox
|
||||
- Safari
|
||||
- Other (please specify)
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is. Include screenshots if applicable.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the problem here.
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,6 +1,7 @@
|
||||
name: 🛠️ API issue (api.modrinth.com)
|
||||
description: Report an issue regarding the Modrinth API.
|
||||
labels: [bug, backend]
|
||||
name: 🛠 Bug with Modrinth API
|
||||
description: For issues with the Modrinth API for developers.
|
||||
labels: [api, backend]
|
||||
type: 'bug'
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
+6
-5
@@ -1,6 +1,6 @@
|
||||
name: 💡 Feature Request
|
||||
name: 💡 Feature request
|
||||
description: Suggest an idea
|
||||
labels: [enhancement]
|
||||
type: 'feature'
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
@@ -17,9 +17,10 @@ body:
|
||||
label: What parts of Modrinth is your feature request related too?
|
||||
multiple: true
|
||||
options:
|
||||
- App
|
||||
- Website
|
||||
- API
|
||||
- Modrinth App
|
||||
- Modrinth.com website
|
||||
- Modrinth Servers
|
||||
- Modrinth API for developers
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Is your suggested feature related to a problem? Please describe.
|
||||
@@ -1,14 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 🫶 Support Portal
|
||||
about: Get support using through our portal.
|
||||
- name: 🫶 Support portal
|
||||
about: Get support using through our support website.
|
||||
url: https://support.modrinth.com
|
||||
- name: 💬 Chat
|
||||
- name: 💬 Chat on Discord
|
||||
about: Join our Discord server to chat about Modrinth.
|
||||
url: https://discord.modrinth.com
|
||||
- name: 🛣️ Roadmap
|
||||
about: View our Roadmap. Please do not open issues for items on our roadmap.
|
||||
url: https://roadmap.modrinth.com
|
||||
- name: 📚 Documentation
|
||||
about: Useful documentation about Modrinth's API
|
||||
url: https://docs.modrinth.com
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
SQLX_OFFLINE: true
|
||||
|
||||
jobs:
|
||||
typos:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: crate-ci/typos@master
|
||||
|
||||
tombi:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: tombi-toml/setup-tombi@v1
|
||||
- run: tombi lint
|
||||
- run: tombi fmt --check
|
||||
@@ -0,0 +1,19 @@
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
SQLX_OFFLINE: true
|
||||
|
||||
jobs:
|
||||
shear:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: cargo-bins/cargo-binstall@main
|
||||
- run: cargo binstall --no-confirm cargo-shear
|
||||
- run: cargo shear
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
jobs:
|
||||
pull_translations:
|
||||
name: 'Pull translations from Crowdin'
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
concurrency:
|
||||
group: i18n-pull:${{ github.ref }}
|
||||
|
||||
@@ -18,7 +18,7 @@ concurrency:
|
||||
jobs:
|
||||
push_translations:
|
||||
name: Push sources to Crowdin
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
concurrency:
|
||||
group: i18n-push:${{ github.ref }}
|
||||
|
||||
@@ -7,11 +7,15 @@ on:
|
||||
paths:
|
||||
- .github/workflows/labrinth-docker.yml
|
||||
- 'apps/labrinth/**'
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
paths:
|
||||
- .github/workflows/labrinth-docker.yml
|
||||
- 'apps/labrinth/**'
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
|
||||
@@ -28,13 +28,13 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [macos-latest, windows-latest, ubuntu-22.04]
|
||||
platform: [macos-latest, windows-latest, ubuntu-latest]
|
||||
include:
|
||||
- platform: macos-latest
|
||||
artifact-target-name: universal-apple-darwin
|
||||
- platform: windows-latest
|
||||
artifact-target-name: x86_64-pc-windows-msvc
|
||||
- platform: ubuntu-22.04
|
||||
- platform: ubuntu-latest
|
||||
artifact-target-name: x86_64-unknown-linux-gnu
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
run: |
|
||||
[System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream
|
||||
$env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD"
|
||||
$env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
|
||||
$env:JAVA_HOME = "$env:JAVA_HOME_17_X64"
|
||||
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater'
|
||||
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
|
||||
if: startsWith(matrix.platform, 'windows')
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
name: Lint and Test
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
# Ensure pnpm output is colored in GitHub Actions logs
|
||||
|
||||
Vendored
+19
-1
@@ -11,5 +11,23 @@
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "always"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[scss]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[rust]": {
|
||||
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# Architecture
|
||||
|
||||
Use TAB instead of spaces.
|
||||
|
||||
## Frontend
|
||||
|
||||
There are two similar frontends in the Modrinth monorepo, the website (apps/frontend) and the app frontend (apps/app-frontend).
|
||||
|
||||
Both use Tailwind v3, and their respective configs can be seen at `tailwind.config.ts` and `tailwind.config.js` respectively.
|
||||
|
||||
Both utilize shared and common components from `@modrinth/ui` which can be found at `packages/ui`, and stylings from `@modrinth/assets` which can be found at `packages/assets`.
|
||||
|
||||
Both can utilize icons from `@modrinth/assets`, which are automatically generated based on what's available within the `icons` folder of the `packages/assets` directory. You can see the generated icons list in `generated-icons.ts`.
|
||||
|
||||
Both have access to our dependency injection framework, examples as seen in `packages/ui/src/providers/`. Ideally any state which is shared between a page and it's subpages should be shared using this dependency injection framework.
|
||||
|
||||
### Website (apps/frontend)
|
||||
|
||||
Before a pull request can be opened for the website, `pnpm web:fix` and `pnpm web:intl:extract` must be run, otherwise CI will fail.
|
||||
|
||||
To run a development version of the frontend, you must first copy over the relevant `.env` template file (prod, staging or local, usually prod) within the `apps/frontend` folder into `apps/frontend/.env`. Then you can run the frontend by running `pnpm web:dev` in the root folder.
|
||||
|
||||
### App Frontend (apps/app-frontend)
|
||||
|
||||
Before a pull request can be opened for the website, you must CD into the `app-frontend` folder; `pnpm fix` and `pnpm intl:extract` must be run, otherwise CI will fail.
|
||||
|
||||
To run a development version of the app frontend, you must first copy over the relevant `.env` template file (prod, staging or local, usually prod) within `packages/app-lib` into `packages/app-lib/.env`. Then you must run the app itself by running `pnpm app:dev` in the root folder.
|
||||
|
||||
### Localization
|
||||
|
||||
Refer to `.github/instructions/i18n-convert.instructions.md` if the user asks you to perform any i18n conversion work on a component, set of components, pages or sets of pages.
|
||||
|
||||
## Labrinth
|
||||
|
||||
Labrinth is the backend API service for Modrinth.
|
||||
@@ -15,6 +45,7 @@ To prepare the sqlx cache, cd into `apps/labrinth` and run `cargo sqlx prepare`.
|
||||
Read the root `docker-compose.yml` to see what running services are available while developing. Use `docker exec` to access these services.
|
||||
|
||||
When the user refers to "performing pre-PR checks", do the following:
|
||||
|
||||
- Run clippy as described above
|
||||
- DO NOT run tests unless explicitly requested (they take a long time)
|
||||
- Prepare the sqlx cache
|
||||
|
||||
Generated
+572
-295
File diff suppressed because it is too large
Load Diff
+138
-117
@@ -1,167 +1,184 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"apps/app",
|
||||
"apps/app-playground",
|
||||
"apps/daedalus_client",
|
||||
"apps/labrinth",
|
||||
"packages/app-lib",
|
||||
"packages/ariadne",
|
||||
"packages/daedalus",
|
||||
"packages/path-util",
|
||||
"apps/app",
|
||||
"apps/app-playground",
|
||||
"apps/daedalus_client",
|
||||
"apps/labrinth",
|
||||
"packages/app-lib",
|
||||
"packages/ariadne",
|
||||
"packages/daedalus",
|
||||
"packages/modrinth-maxmind",
|
||||
"packages/modrinth-util",
|
||||
"packages/path-util",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
rust-version = "1.90.0"
|
||||
repository = "https://github.com/modrinth/code"
|
||||
|
||||
[workspace.dependencies]
|
||||
actix-cors = "0.7.1"
|
||||
actix-files = "0.6.6"
|
||||
actix-http = "3.11.0"
|
||||
actix-files = "0.6.8"
|
||||
actix-http = "3.11.2"
|
||||
actix-multipart = "0.7.2"
|
||||
actix-rt = "2.10.0"
|
||||
actix-rt = "2.11.0"
|
||||
actix-web = "4.11.0"
|
||||
actix-web-prom = "0.10.0"
|
||||
actix-ws = "0.3.0"
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
ariadne = { path = "packages/ariadne" }
|
||||
async_zip = "0.0.17"
|
||||
async-compression = { version = "0.4.27", default-features = false }
|
||||
async-compression = { version = "0.4.32", default-features = false }
|
||||
async-recursion = "1.1.1"
|
||||
async-stripe = { version = "0.41.0", default-features = false, features = [
|
||||
"runtime-tokio-hyper-rustls",
|
||||
"runtime-tokio-hyper-rustls",
|
||||
] }
|
||||
async-trait = "0.1.89"
|
||||
async-tungstenite = { version = "0.31.0", default-features = false, features = [
|
||||
"futures-03-sink"
|
||||
] }
|
||||
async-trait = "0.1.88"
|
||||
async-tungstenite = { version = "0.30.0", default-features = false, features = ["futures-03-sink"] }
|
||||
async-walkdir = "2.1.0"
|
||||
async_zip = "0.0.18"
|
||||
base64 = "0.22.1"
|
||||
bitflags = "2.9.1"
|
||||
bytemuck = "1.23.1"
|
||||
bitflags = "2.9.4"
|
||||
bytemuck = "1.24.0"
|
||||
bytes = "1.10.1"
|
||||
typed-path = "0.11.0"
|
||||
censor = "0.3.0"
|
||||
chardetng = "0.1.17"
|
||||
chrono = "0.4.41"
|
||||
cidre = { version = "0.11.2", default-features = false, features = ["macos_15_0"] }
|
||||
clap = "4.5.43"
|
||||
clickhouse = "0.13.3"
|
||||
chrono = "0.4.42"
|
||||
cidre = { version = "0.11.3", default-features = false, features = [
|
||||
"macos_15_0"
|
||||
] }
|
||||
clap = "4.5.48"
|
||||
clickhouse = "0.14.0"
|
||||
color-eyre = "0.6.5"
|
||||
color-thief = "0.2.2"
|
||||
console-subscriber = "0.4.1"
|
||||
const_format = "0.2.34"
|
||||
daedalus = { path = "packages/daedalus" }
|
||||
path-util = { path = "packages/path-util" }
|
||||
dashmap = "6.1.0"
|
||||
data-url = "0.3.1"
|
||||
data-url = "0.3.2"
|
||||
deadpool-redis = "0.22.0"
|
||||
derive_more = "2.0.1"
|
||||
directories = "6.0.0"
|
||||
dirs = "6.0.0"
|
||||
discord-rich-presence = "0.2.5"
|
||||
discord-rich-presence = "1.0.0"
|
||||
dotenv-build = "0.1.1"
|
||||
dotenvy = "0.15.7"
|
||||
dunce = "1.0.5"
|
||||
either = "1.15.0"
|
||||
encoding_rs = "0.8.35"
|
||||
enumset = "1.1.7"
|
||||
flate2 = "1.1.2"
|
||||
enumset = "1.1.10"
|
||||
eyre = "0.6.12"
|
||||
flate2 = "1.1.4"
|
||||
fs4 = { version = "0.13.1", default-features = false }
|
||||
futures = { version = "0.3.31", default-features = false }
|
||||
futures = "0.3.31"
|
||||
futures-util = "0.3.31"
|
||||
heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
hickory-resolver = "0.25.2"
|
||||
hmac = "0.12.1"
|
||||
hyper = "1.6.0"
|
||||
tracing-ecs = "0.5.0"
|
||||
hyper = "1.7.0"
|
||||
hyper-rustls = { version = "0.27.7", default-features = false, features = [
|
||||
"http1",
|
||||
"native-tokio",
|
||||
"ring",
|
||||
"tls12",
|
||||
"aws-lc-rs",
|
||||
"http1",
|
||||
"native-tokio",
|
||||
"tls12",
|
||||
] }
|
||||
hyper-util = "0.1.16"
|
||||
iana-time-zone = "0.1.63"
|
||||
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
|
||||
indexmap = "2.10.0"
|
||||
hyper-util = "0.1.17"
|
||||
iana-time-zone = "0.1.64"
|
||||
image = { version = "0.25.8", default-features = false, features = ["rayon"] }
|
||||
indexmap = "2.11.4"
|
||||
indicatif = "0.18.0"
|
||||
itertools = "0.14.0"
|
||||
jemalloc_pprof = "0.8.1"
|
||||
json-patch = { version = "4.0.0", default-features = false }
|
||||
lettre = { version = "0.11.18", default-features = false, features = [
|
||||
"builder",
|
||||
"hostname",
|
||||
"pool",
|
||||
"ring",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"tokio1-rustls",
|
||||
"smtp-transport",
|
||||
"tokio1",
|
||||
json-patch = { version = "4.1.0", default-features = false }
|
||||
lettre = { version = "0.11.19", default-features = false, features = [
|
||||
"aws-lc-rs",
|
||||
"builder",
|
||||
"hostname",
|
||||
"pool",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"smtp-transport",
|
||||
"tokio1",
|
||||
"tokio1-rustls",
|
||||
] }
|
||||
maxminddb = "0.26.0"
|
||||
meilisearch-sdk = { version = "0.29.1", default-features = false }
|
||||
meilisearch-sdk = { version = "0.30.0", default-features = false }
|
||||
modrinth-maxmind = { path = "packages/modrinth-maxmind" }
|
||||
modrinth-util = { path = "packages/modrinth-util" }
|
||||
murmur2 = "0.1.0"
|
||||
native-dialog = "0.9.0"
|
||||
native-dialog = "0.9.2"
|
||||
notify = { version = "8.2.0", default-features = false }
|
||||
notify-debouncer-mini = { version = "0.7.0", default-features = false }
|
||||
p256 = "0.13.2"
|
||||
paste = "1.0.15"
|
||||
phf = { version = "0.12.1", features = ["macros"] }
|
||||
png = "0.17.16"
|
||||
path-util = { path = "packages/path-util" }
|
||||
phf = { version = "0.13.1", features = ["macros"] }
|
||||
png = "0.18.0"
|
||||
prometheus = "0.14.0"
|
||||
quartz_nbt = "0.2.9"
|
||||
quick-xml = "0.38.1"
|
||||
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
|
||||
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
|
||||
redis = "0.32.4"
|
||||
regex = "1.11.1"
|
||||
reqwest = { version = "0.12.22", default-features = false }
|
||||
quick-xml = "0.38.3"
|
||||
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
|
||||
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
|
||||
redis = "0.32.7"
|
||||
regex = "1.12.2"
|
||||
reqwest = { version = "0.12.24", default-features = false }
|
||||
rgb = "0.8.52"
|
||||
rust_decimal = { version = "1.37.2", features = ["serde-with-float", "serde-with-str"] }
|
||||
rust_decimal = { version = "1.39.0", features = [
|
||||
"serde-with-float",
|
||||
"serde-with-str"
|
||||
] }
|
||||
rust_iso3166 = "0.1.14"
|
||||
rust-s3 = { version = "0.35.1", default-features = false, features = [
|
||||
"fail-on-err",
|
||||
"tags",
|
||||
"tokio-rustls-tls",
|
||||
rust-s3 = { version = "0.37.0", default-features = false, features = [
|
||||
"fail-on-err",
|
||||
"tags",
|
||||
"tokio-rustls-tls",
|
||||
] }
|
||||
rustls = "0.23.32"
|
||||
rusty-money = "0.4.1"
|
||||
sentry = { version = "0.42.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
"debug-images",
|
||||
"panic",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
sentry = { version = "0.45.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
"debug-images",
|
||||
"panic",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
] }
|
||||
sentry-actix = "0.42.0"
|
||||
serde = "1.0.219"
|
||||
serde_bytes = "0.11.17"
|
||||
sentry-actix = "0.45.0"
|
||||
serde = "1.0.228"
|
||||
serde_bytes = "0.11.19"
|
||||
serde_cbor = "0.11.2"
|
||||
serde_ini = "0.2.0"
|
||||
serde_json = "1.0.142"
|
||||
serde_with = "3.14.0"
|
||||
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
|
||||
serde_json = "1.0.145"
|
||||
serde_with = "3.15.0"
|
||||
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
|
||||
sha1 = "0.10.6"
|
||||
sha1_smol = { version = "1.0.1", features = ["std"] }
|
||||
sha2 = "0.10.9"
|
||||
spdx = "0.10.9"
|
||||
shlex = "1.3.0"
|
||||
spdx = "0.12.0"
|
||||
sqlx = { version = "0.8.6", default-features = false }
|
||||
sysinfo = { version = "0.36.1", default-features = false }
|
||||
sysinfo = { version = "0.37.2", default-features = false }
|
||||
tar = "0.4.44"
|
||||
tauri = "2.7.0"
|
||||
tauri-build = "2.3.1"
|
||||
tauri-plugin-deep-link = "2.4.1"
|
||||
tauri-plugin-dialog = "2.3.2"
|
||||
tauri-plugin-http = "2.5.1"
|
||||
tauri-plugin-opener = "2.4.0"
|
||||
tauri-plugin-os = "2.3.0"
|
||||
tauri-plugin-single-instance = "2.3.2"
|
||||
tauri = "2.8.5"
|
||||
tauri-build = "2.4.1"
|
||||
tauri-plugin-deep-link = "2.4.3"
|
||||
tauri-plugin-dialog = "2.4.0"
|
||||
tauri-plugin-http = "2.5.2"
|
||||
tauri-plugin-opener = "2.5.0"
|
||||
tauri-plugin-os = "2.3.1"
|
||||
tauri-plugin-single-instance = "2.3.4"
|
||||
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
"zip",
|
||||
"rustls-tls",
|
||||
"zip",
|
||||
] }
|
||||
tauri-plugin-window-state = "2.4.0"
|
||||
tempfile = "3.20.0"
|
||||
tempfile = "3.23.0"
|
||||
theseus = { path = "packages/app-lib" }
|
||||
thiserror = "2.0.12"
|
||||
thiserror = "2.0.17"
|
||||
tikv-jemalloc-ctl = "0.6.0"
|
||||
tikv-jemallocator = "0.6.0"
|
||||
tokio = "1.47.1"
|
||||
@@ -170,28 +187,31 @@ tokio-util = "0.7.16"
|
||||
totp-rs = "5.7.0"
|
||||
tracing = "0.1.41"
|
||||
tracing-actix-web = { version = "0.7.19", default-features = false }
|
||||
tracing-ecs = "0.5.0"
|
||||
tracing-error = "0.2.1"
|
||||
tracing-subscriber = "0.3.19"
|
||||
eyre = "0.6.12"
|
||||
color-eyre = "0.6.5"
|
||||
url = "2.5.4"
|
||||
tracing-subscriber = "0.3.20"
|
||||
typed-path = "0.12.0"
|
||||
url = "2.5.7"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = "1.17.0"
|
||||
utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "decimal"] }
|
||||
utoipa-actix-web = { version = "0.1.2" }
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web", "vendored"] }
|
||||
uuid = "1.18.1"
|
||||
validator = "0.20.0"
|
||||
webp = { version = "0.3.0", default-features = false }
|
||||
whoami = "1.6.0"
|
||||
windows = "0.61.3"
|
||||
windows-core = "0.61.2"
|
||||
webview2-com = "0.38.0" # Should be updated in lockstep with wry
|
||||
webp = { version = "0.3.1", default-features = false }
|
||||
webview2-com = "0.38.0" # Should be updated in lockstep with wry
|
||||
whoami = "1.6.1"
|
||||
windows = "=0.61.3" # Locked on 0.61 until we can update windows-core to 0.62
|
||||
windows-core = "=0.61.2" # Locked on 0.61 until webview2-com updates to 0.62
|
||||
winreg = "0.55.0"
|
||||
woothee = "0.13.0"
|
||||
yaserde = "0.12.0"
|
||||
zbus = "5.9.0"
|
||||
zip = { version = "4.3.0", default-features = false, features = [
|
||||
"bzip2",
|
||||
"deflate",
|
||||
"deflate64",
|
||||
"zstd",
|
||||
zbus = "5.11.0"
|
||||
zip = { version = "6.0.0", default-features = false, features = [
|
||||
"bzip2",
|
||||
"deflate",
|
||||
"deflate64",
|
||||
"zstd",
|
||||
] }
|
||||
zxcvbn = "3.1.0"
|
||||
|
||||
@@ -226,21 +246,22 @@ redundant_clone = "warn"
|
||||
redundant_feature_names = "warn"
|
||||
redundant_type_annotations = "warn"
|
||||
todo = "warn"
|
||||
uninlined_format_args = "warn"
|
||||
unnested_or_patterns = "warn"
|
||||
wildcard_dependencies = "warn"
|
||||
|
||||
[profile.dev.package.sqlx-macros]
|
||||
opt-level = 3
|
||||
|
||||
# Optimize for speed and reduce size on release builds
|
||||
[profile.release]
|
||||
opt-level = "s" # Optimize for binary size
|
||||
strip = true # Remove debug symbols
|
||||
lto = true # Enables link to optimizations
|
||||
panic = "abort" # Strip expensive panic clean-up logic
|
||||
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
|
||||
opt-level = "s" # Optimize for binary size
|
||||
strip = true # Remove debug symbols
|
||||
lto = true # Enables link to optimizations
|
||||
panic = "abort" # Strip expensive panic clean-up logic
|
||||
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
|
||||
|
||||
# Specific profile for labrinth production builds
|
||||
[profile.release-labrinth]
|
||||
inherits = "release"
|
||||
panic = "unwind" # Don't exit the whole app on panic in production
|
||||
|
||||
[profile.dev.package.sqlx-macros]
|
||||
opt-level = 3
|
||||
panic = "unwind" # Don't exit the whole app on panic in production
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
[files]
|
||||
extend-exclude = [
|
||||
"**/src/locales/",
|
||||
"apps/frontend/",
|
||||
"patches/",
|
||||
"packages/utils/",
|
||||
"packages/ui/",
|
||||
"packages/blog/",
|
||||
# contains licenses like `CC-BY-ND-4.0`
|
||||
"packages/moderation/src/data/stages/license.ts",
|
||||
# contains payment card IDs like `IY1VMST1MOXS` which are flagged
|
||||
"apps/labrinth/src/queue/payouts.rs",
|
||||
]
|
||||
|
||||
[default.extend-words]
|
||||
# Terms Of Use in `tou-link`
|
||||
tou = "tou"
|
||||
# Google Ad Manager
|
||||
gam = "gam"
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ChangeSkinIcon,
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
HomeIcon,
|
||||
LeftArrowIcon,
|
||||
LibraryIcon,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
RestoreIcon,
|
||||
RightArrowIcon,
|
||||
SettingsIcon,
|
||||
UserIcon,
|
||||
WorldIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
@@ -69,7 +71,7 @@ import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/he
|
||||
import { get_user } from '@/helpers/cache.js'
|
||||
import { command_listener, warning_listener } from '@/helpers/events.js'
|
||||
import { useFetch } from '@/helpers/fetch.js'
|
||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
|
||||
import { list } from '@/helpers/profile.js'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
import { get_opening_command, initialize_state } from '@/helpers/state'
|
||||
@@ -817,29 +819,39 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<SettingsIcon />
|
||||
</NavButton>
|
||||
<ButtonStyled v-if="credentials" type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'sign-out',
|
||||
action: () => logOut(),
|
||||
color: 'danger',
|
||||
},
|
||||
]"
|
||||
direction="left"
|
||||
>
|
||||
<Avatar
|
||||
:src="credentials.user.avatar_url"
|
||||
:alt="credentials.user.username"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<template #sign-out> <LogOutIcon /> Sign out </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<NavButton v-else v-tooltip.right="'Sign in'" :to="() => signIn()">
|
||||
<LogInIcon />
|
||||
<template #label>Sign in</template>
|
||||
<OverflowMenu
|
||||
v-if="credentials"
|
||||
v-tooltip.right="`Modrinth account`"
|
||||
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast border-0 cursor-pointer"
|
||||
:options="[
|
||||
{
|
||||
id: 'view-profile',
|
||||
action: () => openUrl('https://modrinth.com/user/' + credentials.user.username),
|
||||
},
|
||||
{
|
||||
id: 'sign-out',
|
||||
action: () => logOut(),
|
||||
color: 'danger',
|
||||
},
|
||||
]"
|
||||
placement="right-end"
|
||||
>
|
||||
<Avatar :src="credentials.user.avatar_url" alt="" size="32px" circle />
|
||||
<template #view-profile>
|
||||
<UserIcon />
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Signed in as
|
||||
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
|
||||
<Avatar :src="credentials.user.avatar_url" alt="" size="20px" circle />
|
||||
{{ credentials.user.username }}
|
||||
</span>
|
||||
</span>
|
||||
<ExternalIcon />
|
||||
</template>
|
||||
<template #sign-out> <LogOutIcon /> Sign out </template>
|
||||
</OverflowMenu>
|
||||
<NavButton v-else v-tooltip.right="'Sign in to a Modrinth account'" :to="() => signIn()">
|
||||
<LogInIcon class="text-brand" />
|
||||
</NavButton>
|
||||
</div>
|
||||
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
|
||||
@@ -978,20 +990,26 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
|
||||
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
|
||||
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||
<h3 class="text-lg m-0">Playing as</h3>
|
||||
<div
|
||||
class="p-4 pr-1 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
||||
>
|
||||
<h3 class="text-base text-primary font-medium m-0">Playing as</h3>
|
||||
<suspense>
|
||||
<AccountsCard ref="accounts" mode="small" />
|
||||
</suspense>
|
||||
</div>
|
||||
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||
<div class="py-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||
<suspense>
|
||||
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
|
||||
<FriendsList
|
||||
:credentials="credentials"
|
||||
:sign-in="() => signIn()"
|
||||
:refresh-credentials="fetchCredentials"
|
||||
/>
|
||||
</suspense>
|
||||
</div>
|
||||
<div v-if="news && news.length > 0" class="pt-4 flex flex-col items-center">
|
||||
<h3 class="px-4 text-lg m-0 text-left w-full">News</h3>
|
||||
<div class="px-4 pt-2 space-y-4 flex flex-col items-center w-full">
|
||||
<div v-if="news && news.length > 0" class="p-4 pr-1 flex flex-col items-center">
|
||||
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">News</h3>
|
||||
<div class="space-y-4 flex flex-col items-center w-full">
|
||||
<NewsArticleCard
|
||||
v-for="(item, index) in news"
|
||||
:key="`news-${index}`"
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['menu-closed', 'option-clicked'])
|
||||
|
||||
@@ -40,22 +40,27 @@ defineExpose({
|
||||
item.value = passedItem
|
||||
options.value = passedOptions
|
||||
|
||||
const menuWidth = contextMenu.value.clientWidth
|
||||
const menuHeight = contextMenu.value.clientHeight
|
||||
|
||||
if (menuWidth + event.pageX >= window.innerWidth) {
|
||||
left.value = event.pageX - menuWidth + 2 + 'px'
|
||||
} else {
|
||||
left.value = event.pageX - 2 + 'px'
|
||||
}
|
||||
|
||||
if (menuHeight + event.pageY >= window.innerHeight) {
|
||||
top.value = event.pageY - menuHeight + 2 + 'px'
|
||||
} else {
|
||||
top.value = event.pageY - 2 + 'px'
|
||||
}
|
||||
|
||||
// show to get dimensions
|
||||
shown.value = true
|
||||
|
||||
// then, adjust position if overflowing
|
||||
nextTick(() => {
|
||||
const menuWidth = contextMenu.value?.clientWidth || 200
|
||||
const menuHeight = contextMenu.value?.clientHeight || 100
|
||||
const minFromEdge = 10
|
||||
|
||||
if (event.pageX + menuWidth + minFromEdge >= window.innerWidth) {
|
||||
left.value = Math.max(minFromEdge, event.pageX - menuWidth - minFromEdge) + 'px'
|
||||
} else {
|
||||
left.value = event.pageX + minFromEdge + 'px'
|
||||
}
|
||||
|
||||
if (event.pageY + menuHeight + minFromEdge >= window.innerHeight) {
|
||||
top.value = Math.max(minFromEdge, event.pageY - menuHeight - minFromEdge) + 'px'
|
||||
} else {
|
||||
top.value = event.pageY + minFromEdge + 'px'
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -39,12 +39,12 @@ function updateAdPosition() {
|
||||
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
|
||||
>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/medal-modrinth-servers-light.webp"
|
||||
src="https://cdn-raw.modrinth.com/medal-modrinth-servers-light-new.webp"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
class="hidden light-image rounded-[inherit]"
|
||||
/>
|
||||
<img
|
||||
src="https://cdn-raw.modrinth.com/medal-modrinth-servers-dark.webp"
|
||||
src="https://cdn-raw.modrinth.com/medal-modrinth-servers-dark-new.webp"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
class="dark-image rounded-[inherit]"
|
||||
/>
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
MailIcon,
|
||||
MoreVerticalIcon,
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
UserPlusIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get_user_many } from '@/helpers/cache'
|
||||
import { friend_listener } from '@/helpers/events'
|
||||
import { add_friend, friend_statuses, friends, remove_friend } from '@/helpers/friends'
|
||||
import {
|
||||
add_friend,
|
||||
friends,
|
||||
type FriendWithUserData,
|
||||
remove_friend,
|
||||
transformFriends,
|
||||
} from '@/helpers/friends.ts'
|
||||
import type { ModrinthCredentials } from '@/helpers/mr_auth'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const props = defineProps<{
|
||||
credentials: unknown | null
|
||||
credentials: ModrinthCredentials | null
|
||||
signIn: () => void
|
||||
}>()
|
||||
|
||||
const userCredentials = computed(() => props.credentials)
|
||||
|
||||
const search = ref('')
|
||||
const manageFriendsModal = ref()
|
||||
const friendInvitesModal = ref()
|
||||
|
||||
const username = ref('')
|
||||
@@ -47,61 +47,64 @@ async function addFriendFromModal() {
|
||||
await loadFriends()
|
||||
}
|
||||
|
||||
const friendOptions = ref()
|
||||
async function handleFriendOptions(args) {
|
||||
switch (args.option) {
|
||||
case 'remove-friend':
|
||||
await removeFriend(args.item)
|
||||
break
|
||||
async function addFriend(friend: FriendWithUserData) {
|
||||
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
|
||||
if (id) {
|
||||
await add_friend(id).catch(handleError)
|
||||
await loadFriends()
|
||||
}
|
||||
}
|
||||
|
||||
async function addFriend(friend: Friend) {
|
||||
await add_friend(
|
||||
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
|
||||
).catch(handleError)
|
||||
await loadFriends()
|
||||
async function removeFriend(friend: FriendWithUserData) {
|
||||
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
|
||||
if (id) {
|
||||
await remove_friend(id).catch(handleError)
|
||||
await loadFriends()
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFriend(friend: Friend) {
|
||||
await remove_friend(
|
||||
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
|
||||
).catch(handleError)
|
||||
await loadFriends()
|
||||
}
|
||||
const userFriends = ref<FriendWithUserData[]>([])
|
||||
const sortedFriends = computed<FriendWithUserData[]>(() =>
|
||||
userFriends.value.slice().sort((a, b) => {
|
||||
if (a.last_updated === null && b.last_updated === null) {
|
||||
return 0 // Both are null, equal in sorting
|
||||
}
|
||||
if (a.last_updated === null) {
|
||||
return 1 // `a` is null, move it after `b`
|
||||
}
|
||||
if (b.last_updated === null) {
|
||||
return -1 // `b` is null, move it after `a`
|
||||
}
|
||||
// Both are non-null, sort by date
|
||||
return b.last_updated.diff(a.last_updated)
|
||||
}),
|
||||
)
|
||||
const filteredFriends = computed<FriendWithUserData[]>(() =>
|
||||
sortedFriends.value.filter((x) =>
|
||||
x.username.trim().toLowerCase().includes(search.value.trim().toLowerCase()),
|
||||
),
|
||||
)
|
||||
|
||||
type Friend = {
|
||||
id: string
|
||||
friend_id: string | null
|
||||
status: string | null
|
||||
last_updated: Dayjs | null
|
||||
created: Dayjs
|
||||
username: string
|
||||
accepted: boolean
|
||||
online: boolean
|
||||
avatar: string
|
||||
}
|
||||
|
||||
const userFriends = ref<Friend[]>([])
|
||||
const acceptedFriends = computed(() =>
|
||||
userFriends.value
|
||||
.filter((x) => x.accepted)
|
||||
.toSorted((a, b) => {
|
||||
if (a.last_updated === null && b.last_updated === null) {
|
||||
return 0 // Both are null, equal in sorting
|
||||
}
|
||||
if (a.last_updated === null) {
|
||||
return 1 // `a` is null, move it after `b`
|
||||
}
|
||||
if (b.last_updated === null) {
|
||||
return -1 // `b` is null, move it after `a`
|
||||
}
|
||||
// Both are non-null, sort by date
|
||||
return b.last_updated.diff(a.last_updated)
|
||||
}),
|
||||
const activeFriends = computed<FriendWithUserData[]>(() =>
|
||||
filteredFriends.value.filter((x) => !!x.status && x.online && x.accepted),
|
||||
)
|
||||
const onlineFriends = computed<FriendWithUserData[]>(() =>
|
||||
filteredFriends.value.filter((x) => x.online && !x.status && x.accepted),
|
||||
)
|
||||
const offlineFriends = computed<FriendWithUserData[]>(() =>
|
||||
filteredFriends.value.filter((x) => !x.online && x.accepted),
|
||||
)
|
||||
const pendingFriends = computed(() =>
|
||||
userFriends.value.filter((x) => !x.accepted).toSorted((a, b) => b.created.diff(a.created)),
|
||||
filteredFriends.value
|
||||
.filter((x) => !x.accepted && x.id !== userCredentials.value?.user_id)
|
||||
.slice()
|
||||
.sort((a, b) => b.created.diff(a.created)),
|
||||
)
|
||||
const incomingRequests = computed(() =>
|
||||
userFriends.value
|
||||
.filter((x) => !x.accepted && x.id === userCredentials.value?.user_id)
|
||||
.slice()
|
||||
.sort((a, b) => b.created.diff(a.created)),
|
||||
)
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -110,34 +113,7 @@ async function loadFriends(timeout = false) {
|
||||
|
||||
try {
|
||||
const friendsList = await friends()
|
||||
|
||||
if (friendsList.length === 0) {
|
||||
userFriends.value = []
|
||||
} else {
|
||||
const friendStatuses = await friend_statuses()
|
||||
const users = await get_user_many(
|
||||
friendsList.map((x) => (x.id === userCredentials.value.user_id ? x.friend_id : x.id)),
|
||||
)
|
||||
|
||||
userFriends.value = friendsList.map((friend) => {
|
||||
const user = users.find((x) => x.id === friend.id || x.id === friend.friend_id)
|
||||
const status = friendStatuses.find(
|
||||
(x) => x.user_id === friend.id || x.user_id === friend.friend_id,
|
||||
)
|
||||
return {
|
||||
id: friend.id,
|
||||
friend_id: friend.friend_id,
|
||||
status: status?.profile_name,
|
||||
last_updated: status && status.last_update ? dayjs(status.last_update) : null,
|
||||
created: dayjs(friend.created),
|
||||
avatar: user?.avatar_url,
|
||||
username: user?.username,
|
||||
online: !!status,
|
||||
accepted: friend.accepted,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
userFriends.value = await transformFriends(friendsList, userCredentials.value)
|
||||
loading.value = false
|
||||
} catch (e) {
|
||||
console.error('Error loading friends', e)
|
||||
@@ -152,6 +128,7 @@ watch(
|
||||
() => {
|
||||
if (userCredentials.value === undefined) {
|
||||
userFriends.value = []
|
||||
loading.value = false
|
||||
} else if (userCredentials.value === null) {
|
||||
userFriends.value = []
|
||||
loading.value = false
|
||||
@@ -166,49 +143,87 @@ const unlisten = await friend_listener(() => loadFriends())
|
||||
onUnmounted(() => {
|
||||
unlisten()
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
addFriend: {
|
||||
id: 'friends.action.add-friend',
|
||||
defaultMessage: 'Add a friend',
|
||||
},
|
||||
addingAFriend: {
|
||||
id: 'friends.add-friend.title',
|
||||
defaultMessage: 'Adding a friend',
|
||||
},
|
||||
usernameTitle: {
|
||||
id: 'friends.add-friend.username.title',
|
||||
defaultMessage: "What's your friend's Modrinth username?",
|
||||
},
|
||||
usernameDescription: {
|
||||
id: 'friends.add-friend.username.description',
|
||||
defaultMessage: 'It may be different from their Minecraft username!',
|
||||
},
|
||||
usernamePlaceholder: {
|
||||
id: 'friends.add-friend.username.placeholder',
|
||||
defaultMessage: 'Enter Modrinth username...',
|
||||
},
|
||||
sendFriendRequest: {
|
||||
id: 'friends.add-friend.submit',
|
||||
defaultMessage: 'Send friend request',
|
||||
},
|
||||
viewFriendRequests: {
|
||||
id: 'friends.action.view-friend-requests',
|
||||
defaultMessage: '{count} friend {count, plural, one {request} other {requests}}',
|
||||
},
|
||||
searchFriends: {
|
||||
id: 'friends.search-friends-placeholder',
|
||||
defaultMessage: 'Search friends...',
|
||||
},
|
||||
friends: {
|
||||
id: 'friends.heading',
|
||||
defaultMessage: 'Friends',
|
||||
},
|
||||
pending: {
|
||||
id: 'friends.heading.pending',
|
||||
defaultMessage: 'Pending',
|
||||
},
|
||||
active: {
|
||||
id: 'friends.heading.active',
|
||||
defaultMessage: 'Active',
|
||||
},
|
||||
online: {
|
||||
id: 'friends.heading.online',
|
||||
defaultMessage: 'Online',
|
||||
},
|
||||
offline: {
|
||||
id: 'friends.heading.offline',
|
||||
defaultMessage: 'Offline',
|
||||
},
|
||||
noFriendsMatch: {
|
||||
id: 'friends.no-friends-match',
|
||||
defaultMessage: `No friends matching ''{query}''`,
|
||||
},
|
||||
signInToAddFriends: {
|
||||
id: 'friends.sign-in-to-add-friends',
|
||||
defaultMessage:
|
||||
"<link>Sign in to a Modrinth account</link> to add friends and see what they're playing!",
|
||||
},
|
||||
addFriendsToShare: {
|
||||
id: 'friends.add-friends-to-share',
|
||||
defaultMessage: "<link>Add friends</link> to see what they're playing!",
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="manageFriendsModal" header="Manage friends">
|
||||
<p v-if="acceptedFriends.length === 0">Add friends to share what you're playing!</p>
|
||||
<div v-else class="flex flex-col gap-4 min-w-[20rem]">
|
||||
<input v-model="search" type="text" placeholder="Search friends..." class="w-full" />
|
||||
<div
|
||||
v-for="friend in acceptedFriends.filter(
|
||||
(x) => !search || x.username.toLowerCase().includes(search),
|
||||
)"
|
||||
:key="friend.username"
|
||||
class="flex gap-2 items-center"
|
||||
>
|
||||
<div class="relative">
|
||||
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
|
||||
<span
|
||||
v-if="friend.online"
|
||||
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div>{{ friend.username }}</div>
|
||||
<div class="ml-auto">
|
||||
<ButtonStyled>
|
||||
<button @click="removeFriend(friend)">
|
||||
<XIcon />
|
||||
Remove
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="friendInvitesModal" header="View friend requests">
|
||||
<p v-if="pendingFriends.length === 0">You have no pending friend requests :C</p>
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div v-for="friend in pendingFriends" :key="friend.username" class="flex gap-2">
|
||||
<p v-if="incomingRequests.length === 0">You have no pending friend requests :C</p>
|
||||
<div v-else class="flex flex-col gap-4 min-w-[40rem]">
|
||||
<div v-for="friend in incomingRequests" :key="friend.username" class="flex gap-2">
|
||||
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="grid grid-cols-[1fr_auto] w-full gap-4">
|
||||
<div>
|
||||
<p class="m-0">
|
||||
<template v-if="friend.id === userCredentials.user_id">
|
||||
<span class="font-bold">{{ friend.username }}</span> sent you a friend request
|
||||
<template v-if="friend.id === userCredentials?.user_id">
|
||||
<span class="text-contrast">{{ friend.username }}</span> sent you a friend request
|
||||
</template>
|
||||
<template v-else>
|
||||
You sent <span class="font-bold">{{ friend.username }}</span> a friend request
|
||||
@@ -219,7 +234,7 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<template v-if="friend.id === userCredentials.user_id">
|
||||
<template v-if="friend.id === userCredentials?.user_id">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="addFriend(friend)">
|
||||
<UserPlusIcon />
|
||||
@@ -246,78 +261,89 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="addFriendModal" header="Add a friend">
|
||||
<div class="mb-4">
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Username</h2>
|
||||
<p class="m-0 mt-1 leading-tight">You can add friends with their Modrinth username.</p>
|
||||
<input
|
||||
v-model="username"
|
||||
class="mt-2 w-full"
|
||||
type="text"
|
||||
placeholder="Enter username..."
|
||||
@keyup.enter="addFriendFromModal"
|
||||
/>
|
||||
<ModalWrapper ref="addFriendModal" :header="formatMessage(messages.addingAFriend)">
|
||||
<div class="min-w-[30rem]">
|
||||
<h2 class="m-0 text-base font-medium text-primary">
|
||||
{{ formatMessage(messages.usernameTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1 text-sm text-secondary leading-tight">
|
||||
{{ formatMessage(messages.usernameDescription) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<div class="iconified-input flex-1">
|
||||
<UserIcon aria-hidden="true" />
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.usernamePlaceholder)"
|
||||
@keyup.enter="addFriendFromModal"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="username.length === 0" @click="addFriendFromModal">
|
||||
<SendIcon />
|
||||
{{ formatMessage(messages.sendFriendRequest) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="username.length === 0" @click="addFriendFromModal">
|
||||
<UserPlusIcon />
|
||||
Add friend
|
||||
</ModalWrapper>
|
||||
<div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 ml-2 mr-1">
|
||||
<template v-if="sortedFriends.length > 0">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.addFriend)"
|
||||
:aria-label="formatMessage(messages.addFriend)"
|
||||
@click="addFriendModal.show"
|
||||
>
|
||||
<UserPlusIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="iconified-input flex-1">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="friends-search-bar flex w-full"
|
||||
:placeholder="formatMessage(messages.searchFriends)"
|
||||
@keyup.esc="search = ''"
|
||||
/>
|
||||
<button
|
||||
v-if="search"
|
||||
v-tooltip="formatMessage(commonMessages.clearButton)"
|
||||
class="r-btn flex items-center justify-center bg-transparent button-animation p-2 cursor-pointer appearance-none border-none"
|
||||
@click="search = ''"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<h3 v-else class="ml-2 w-full text-base text-primary font-medium m-0">
|
||||
{{ formatMessage(messages.friends) }}
|
||||
</h3>
|
||||
<ButtonStyled v-if="incomingRequests.length > 0" circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
|
||||
class="relative"
|
||||
:aria-label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
|
||||
@click="friendInvitesModal.show"
|
||||
>
|
||||
<MailIcon />
|
||||
<span
|
||||
v-if="incomingRequests.length > 0"
|
||||
aria-hidden="true"
|
||||
class="absolute bg-brand text-brand-inverted text-[8px] top-0.5 px-1 right-0.5 min-w-3 h-3 rounded-full flex items-center justify-center font-bold"
|
||||
>
|
||||
{{ incomingRequests.length }}
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</ModalWrapper>
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg m-0">Friends</h3>
|
||||
<ButtonStyled v-if="userCredentials" type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'add-friend',
|
||||
action: () => addFriendModal.show(),
|
||||
},
|
||||
{
|
||||
id: 'manage-friends',
|
||||
action: () => manageFriendsModal.show(),
|
||||
shown: acceptedFriends.length > 0,
|
||||
},
|
||||
{
|
||||
id: 'view-requests',
|
||||
action: () => friendInvitesModal.show(),
|
||||
shown: pendingFriends.length > 0,
|
||||
},
|
||||
]"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #add-friend>
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
Add friend
|
||||
</template>
|
||||
<template #manage-friends>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
Manage friends
|
||||
<div
|
||||
v-if="acceptedFriends.length > 0"
|
||||
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
|
||||
>
|
||||
{{ acceptedFriends.length }}
|
||||
</div>
|
||||
</template>
|
||||
<template #view-requests>
|
||||
<MailIcon aria-hidden="true" />
|
||||
View friend requests
|
||||
<div
|
||||
v-if="pendingFriends.length > 0"
|
||||
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
|
||||
>
|
||||
{{ pendingFriends.length }}
|
||||
</div>
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3 v-if="loading" class="ml-4 mr-1 text-base text-primary font-medium m-0">
|
||||
{{ formatMessage(messages.friends) }}
|
||||
</h3>
|
||||
<template v-if="loading">
|
||||
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse">
|
||||
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse ml-4 mr-1">
|
||||
<div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div>
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div>
|
||||
@@ -325,50 +351,77 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="acceptedFriends.length === 0">
|
||||
<div class="text-sm">
|
||||
<template v-else-if="sortedFriends.length === 0">
|
||||
<div class="text-sm ml-4 mr-1">
|
||||
<div v-if="!userCredentials">
|
||||
<span class="text-link cursor-pointer" @click="signIn">Sign in</span> to add friends!
|
||||
<IntlFormatted :message-id="messages.signInToAddFriends">
|
||||
<template #link="{ children }">
|
||||
<span class="font-semibold text-brand cursor-pointer" @click="signIn">
|
||||
<component :is="() => children" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
<div v-else>
|
||||
<span class="text-link cursor-pointer" @click="addFriendModal.show()">Add friends</span>
|
||||
to share what you're playing!
|
||||
<IntlFormatted :message-id="messages.addFriendsToShare">
|
||||
<template #link="{ children }">
|
||||
<span class="font-semibold text-brand cursor-pointer" @click="addFriendModal.show">
|
||||
<component :is="() => children" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
|
||||
<template #remove-friend> <TrashIcon /> Remove friend </template>
|
||||
</ContextMenu>
|
||||
<div
|
||||
v-for="friend in acceptedFriends.slice(0, 5)"
|
||||
:key="friend.username"
|
||||
class="flex gap-2 items-center"
|
||||
:class="{ grayscale: !friend.online }"
|
||||
@contextmenu.prevent.stop="
|
||||
(event) =>
|
||||
friendOptions.showMenu(event, friend, [
|
||||
{
|
||||
name: 'remove-friend',
|
||||
color: 'danger',
|
||||
},
|
||||
])
|
||||
"
|
||||
>
|
||||
<div class="relative">
|
||||
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
|
||||
<span
|
||||
v-if="friend.online"
|
||||
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-md m-0 font-medium" :class="{ 'text-secondary': !friend.online }">
|
||||
{{ friend.username }}
|
||||
</span>
|
||||
<span v-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<FriendsSection
|
||||
v-if="activeFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
open-by-default
|
||||
:friends="activeFriends"
|
||||
:heading="formatMessage(messages.active)"
|
||||
:remove-friend="removeFriend"
|
||||
/>
|
||||
<FriendsSection
|
||||
v-if="onlineFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
open-by-default
|
||||
:friends="onlineFriends"
|
||||
:heading="formatMessage(messages.online)"
|
||||
:remove-friend="removeFriend"
|
||||
/>
|
||||
<FriendsSection
|
||||
v-if="offlineFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
:open-by-default="activeFriends.length + onlineFriends.length < 3"
|
||||
:friends="offlineFriends"
|
||||
:heading="formatMessage(messages.offline)"
|
||||
:remove-friend="removeFriend"
|
||||
/>
|
||||
<FriendsSection
|
||||
v-if="pendingFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
open-by-default
|
||||
:friends="pendingFriends"
|
||||
:heading="formatMessage(messages.pending)"
|
||||
:remove-friend="removeFriend"
|
||||
/>
|
||||
<p v-if="filteredFriends.length === 0 && search" class="text-sm text-secondary my-1 mx-4">
|
||||
{{ formatMessage(messages.noFriendsMatch, { query: search }) }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.friends-search-bar {
|
||||
background: none;
|
||||
border: 2px solid var(--color-button-bg) !important;
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.friends-search-bar::placeholder {
|
||||
@apply text-sm font-normal;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { MoreVerticalIcon, TrashIcon, UserIcon, XIcon } from '@modrinth/assets'
|
||||
import { Accordion, Avatar, ButtonStyled, OverflowMenu } from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type { FriendWithUserData } from '@/helpers/friends.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
friends: FriendWithUserData[]
|
||||
heading: string
|
||||
removeFriend: (friend: FriendWithUserData) => Promise<void>
|
||||
isSearching?: boolean
|
||||
openByDefault?: boolean
|
||||
}>(),
|
||||
{
|
||||
isSearching: false,
|
||||
openByDefault: false,
|
||||
},
|
||||
)
|
||||
|
||||
function createContextMenuOptions(friend: FriendWithUserData) {
|
||||
if (friend.accepted) {
|
||||
return [
|
||||
{
|
||||
name: 'view-profile',
|
||||
},
|
||||
{
|
||||
name: 'remove-friend',
|
||||
color: 'danger',
|
||||
},
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
name: 'view-profile',
|
||||
},
|
||||
{
|
||||
name: 'cancel-request',
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function openProfile(username: string) {
|
||||
openUrl('https://modrinth.com/user/' + username)
|
||||
}
|
||||
|
||||
const friendOptions = useTemplateRef('friendOptions')
|
||||
async function handleFriendOptions(args: { item: FriendWithUserData; option: string }) {
|
||||
switch (args.option) {
|
||||
case 'remove-friend':
|
||||
case 'cancel-request':
|
||||
await props.removeFriend(args.item)
|
||||
break
|
||||
case 'view-profile':
|
||||
openProfile(args.item.username)
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
removeFriend: {
|
||||
id: 'friends.friend.remove-friend',
|
||||
defaultMessage: 'Remove friend',
|
||||
},
|
||||
heading: {
|
||||
id: 'friends.section.heading',
|
||||
defaultMessage: '{title} - {count}',
|
||||
},
|
||||
friendRequestSent: {
|
||||
id: 'friends.friend.request-sent',
|
||||
defaultMessage: 'Friend request sent',
|
||||
},
|
||||
cancelRequest: {
|
||||
id: 'friends.friend.cancel-request',
|
||||
defaultMessage: 'Cancel request',
|
||||
},
|
||||
viewProfile: {
|
||||
id: 'friends.friend.view-profile',
|
||||
defaultMessage: 'View profile',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
|
||||
<template #view-profile>
|
||||
<UserIcon />
|
||||
{{ formatMessage(messages.viewProfile) }}
|
||||
</template>
|
||||
<template #remove-friend> <TrashIcon /> {{ formatMessage(messages.removeFriend) }} </template>
|
||||
<template #cancel-request> <XIcon /> {{ formatMessage(messages.cancelRequest) }} </template>
|
||||
</ContextMenu>
|
||||
<Accordion
|
||||
:open-by-default="openByDefault"
|
||||
:force-open="isSearching"
|
||||
:button-class="
|
||||
'pl-4 pr-3 flex w-full items-center bg-transparent border-0 p-0' +
|
||||
(isSearching
|
||||
? ''
|
||||
: ' cursor-pointer hover:brightness-[--hover-brightness] active:scale-[0.98] transition-all')
|
||||
"
|
||||
>
|
||||
<template #title>
|
||||
<h3 class="text-base text-primary font-medium m-0">
|
||||
{{ formatMessage(messages.heading, { title: heading, count: friends.length }) }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="pt-3 flex flex-col gap-1">
|
||||
<div
|
||||
v-for="friend in friends"
|
||||
:key="friend.username"
|
||||
class="group grid items-center grid-cols-[auto_1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full ml-4 mr-1"
|
||||
@contextmenu.prevent.stop="
|
||||
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
|
||||
"
|
||||
>
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:src="friend.avatar"
|
||||
:class="{ grayscale: !friend.online && friend.accepted }"
|
||||
class="w-12 h-12 rounded-full"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<span
|
||||
v-if="friend.online"
|
||||
aria-hidden="true"
|
||||
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-sm m-0"
|
||||
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ friend.username }}
|
||||
</span>
|
||||
<span v-if="!friend.accepted" class="m-0 text-xs">
|
||||
{{ formatMessage(messages.friendRequestSent) }}
|
||||
</span>
|
||||
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
|
||||
</div>
|
||||
<ButtonStyled v-if="friend.accepted" circular type="transparent">
|
||||
<OverflowMenu
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:options="[
|
||||
{
|
||||
id: 'view-profile',
|
||||
action: () => openProfile(friend.username),
|
||||
},
|
||||
{
|
||||
id: 'remove-friend',
|
||||
action: () => removeFriend(friend),
|
||||
color: 'red',
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #view-profile>
|
||||
<UserIcon />
|
||||
{{ formatMessage(messages.viewProfile) }}
|
||||
</template>
|
||||
<template #remove-friend>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.removeFriend) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else type="transparent" circular>
|
||||
<button v-tooltip="formatMessage(messages.cancelRequest)" @click="removeFriend(friend)">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Accordion>
|
||||
</template>
|
||||
@@ -130,6 +130,7 @@ import {
|
||||
remove_custom_skin,
|
||||
type Skin,
|
||||
type SkinModel,
|
||||
type SkinTextureUrl,
|
||||
unequip_skin,
|
||||
} from '@/helpers/skins.ts'
|
||||
|
||||
@@ -142,7 +143,7 @@ const currentSkin = ref<Skin | null>(null)
|
||||
const shouldRestoreModal = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const uploadedTextureUrl = ref<string | null>(null)
|
||||
const uploadedTextureUrl = ref<SkinTextureUrl | null>(null)
|
||||
const previewSkin = ref<string>('')
|
||||
|
||||
const variant = ref<SkinModel>('CLASSIC')
|
||||
@@ -188,7 +189,7 @@ function getSortedCapeExcluding(excludeId: string): Cape | undefined {
|
||||
|
||||
async function loadPreviewSkin() {
|
||||
if (uploadedTextureUrl.value) {
|
||||
previewSkin.value = uploadedTextureUrl.value
|
||||
previewSkin.value = uploadedTextureUrl.value.normalized
|
||||
} else if (currentSkin.value) {
|
||||
try {
|
||||
previewSkin.value = await get_normalized_skin_texture(currentSkin.value)
|
||||
@@ -253,11 +254,11 @@ async function show(e: MouseEvent, skin?: Skin) {
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
async function showNew(e: MouseEvent, skinTextureUrl: string) {
|
||||
async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
|
||||
mode.value = 'new'
|
||||
currentSkin.value = null
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
variant.value = await determineModelType(skinTextureUrl)
|
||||
variant.value = await determineModelType(skinTextureUrl.original)
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
@@ -267,7 +268,7 @@ async function showNew(e: MouseEvent, skinTextureUrl: string) {
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
async function restoreWithNewTexture(skinTextureUrl: string) {
|
||||
async function restoreWithNewTexture(skinTextureUrl: SkinTextureUrl) {
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
await loadPreviewSkin()
|
||||
|
||||
@@ -361,7 +362,7 @@ async function save() {
|
||||
let textureUrl: string
|
||||
|
||||
if (uploadedTextureUrl.value) {
|
||||
textureUrl = uploadedTextureUrl.value
|
||||
textureUrl = uploadedTextureUrl.value.original
|
||||
} else {
|
||||
textureUrl = currentSkin.value!.texture
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export async function friends() {
|
||||
return await invoke('plugin:friends|friends')
|
||||
}
|
||||
|
||||
export async function friend_statuses() {
|
||||
return await invoke('plugin:friends|friend_statuses')
|
||||
}
|
||||
|
||||
export async function add_friend(userId) {
|
||||
return await invoke('plugin:friends|add_friend', { userId })
|
||||
}
|
||||
|
||||
export async function remove_friend(userId) {
|
||||
return await invoke('plugin:friends|remove_friend', { userId })
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { User } from '@modrinth/utils'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { get_user_many } from '@/helpers/cache'
|
||||
import type { ModrinthCredentials } from '@/helpers/mr_auth'
|
||||
|
||||
export type UserStatus = {
|
||||
user_id: string
|
||||
profile_name: string | null
|
||||
last_update: string
|
||||
}
|
||||
|
||||
export type UserFriend = {
|
||||
id: string
|
||||
friend_id: string
|
||||
accepted: boolean
|
||||
created: string
|
||||
}
|
||||
|
||||
export async function friends(): Promise<UserFriend[]> {
|
||||
return await invoke('plugin:friends|friends')
|
||||
}
|
||||
|
||||
export async function friend_statuses(): Promise<UserStatus[]> {
|
||||
return await invoke('plugin:friends|friend_statuses')
|
||||
}
|
||||
|
||||
export async function add_friend(userId: string): Promise<void> {
|
||||
return await invoke('plugin:friends|add_friend', { userId })
|
||||
}
|
||||
|
||||
export async function remove_friend(userId: string): Promise<void> {
|
||||
return await invoke('plugin:friends|remove_friend', { userId })
|
||||
}
|
||||
|
||||
export type FriendWithUserData = {
|
||||
id: string
|
||||
friend_id: string | null
|
||||
status: string | null
|
||||
last_updated: Dayjs | null
|
||||
created: Dayjs
|
||||
username: string
|
||||
accepted: boolean
|
||||
online: boolean
|
||||
avatar: string
|
||||
}
|
||||
export async function transformFriends(
|
||||
friends: UserFriend[],
|
||||
credentials: ModrinthCredentials | null,
|
||||
): Promise<FriendWithUserData[]> {
|
||||
if (friends.length === 0 || !credentials) {
|
||||
return []
|
||||
}
|
||||
|
||||
const friendStatuses = await friend_statuses()
|
||||
const users = await get_user_many(
|
||||
friends.map((x) => (x.id === credentials.user_id ? x.friend_id : x.id)),
|
||||
)
|
||||
|
||||
return friends.map((friend) => {
|
||||
const user = users.find((x: User) => x.id === friend.id || x.id === friend.friend_id)
|
||||
const status = friendStatuses.find(
|
||||
(x) => x.user_id === friend.id || x.user_id === friend.friend_id,
|
||||
)
|
||||
return {
|
||||
id: friend.id,
|
||||
friend_id: friend.friend_id,
|
||||
status: status?.profile_name ?? null,
|
||||
last_updated: status && status.last_update ? dayjs(status.last_update) : null,
|
||||
created: dayjs(friend.created),
|
||||
avatar: user?.avatar_url ?? '',
|
||||
username: user?.username ?? '',
|
||||
online: !!status,
|
||||
accepted: friend.accepted,
|
||||
}
|
||||
})
|
||||
}
|
||||
+11
-4
@@ -5,18 +5,25 @@
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export async function login() {
|
||||
export type ModrinthCredentials = {
|
||||
session: string
|
||||
expires: string
|
||||
user_id: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export async function login(): Promise<ModrinthCredentials> {
|
||||
return await invoke('plugin:mr-auth|modrinth_login')
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
export async function logout(): Promise<void> {
|
||||
return await invoke('plugin:mr-auth|logout')
|
||||
}
|
||||
|
||||
export async function get() {
|
||||
export async function get(): Promise<ModrinthCredentials | null> {
|
||||
return await invoke('plugin:mr-auth|get')
|
||||
}
|
||||
|
||||
export async function cancelLogin() {
|
||||
export async function cancelLogin(): Promise<void> {
|
||||
return await invoke('plugin:mr-auth|cancel_modrinth_login')
|
||||
}
|
||||
@@ -67,6 +67,8 @@ export type AppSettings = {
|
||||
skipped_update: string | null
|
||||
pending_update_toast_for_version: string | null
|
||||
auto_download_updates: boolean | null
|
||||
|
||||
version: number
|
||||
}
|
||||
|
||||
// Get full settings object
|
||||
|
||||
@@ -22,6 +22,11 @@ export interface Skin {
|
||||
is_equipped: boolean
|
||||
}
|
||||
|
||||
export interface SkinTextureUrl {
|
||||
original: string
|
||||
normalized: string
|
||||
}
|
||||
|
||||
export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[]
|
||||
|
||||
export const DEFAULT_MODELS: Record<string, SkinModel> = {
|
||||
|
||||
@@ -65,6 +65,69 @@
|
||||
"app.update.reload-to-update": {
|
||||
"message": "Reload to install update"
|
||||
},
|
||||
"friends.action.add-friend": {
|
||||
"message": "Add a friend"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} friend {count, plural, one {request} other {requests}}"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Send friend request"
|
||||
},
|
||||
"friends.add-friend.title": {
|
||||
"message": "Adding a friend"
|
||||
},
|
||||
"friends.add-friend.username.description": {
|
||||
"message": "It may be different from their Minecraft username!"
|
||||
},
|
||||
"friends.add-friend.username.placeholder": {
|
||||
"message": "Enter Modrinth username..."
|
||||
},
|
||||
"friends.add-friend.username.title": {
|
||||
"message": "What's your friend's Modrinth username?"
|
||||
},
|
||||
"friends.add-friends-to-share": {
|
||||
"message": "<link>Add friends</link> to see what they're playing!"
|
||||
},
|
||||
"friends.friend.cancel-request": {
|
||||
"message": "Cancel request"
|
||||
},
|
||||
"friends.friend.remove-friend": {
|
||||
"message": "Remove friend"
|
||||
},
|
||||
"friends.friend.request-sent": {
|
||||
"message": "Friend request sent"
|
||||
},
|
||||
"friends.friend.view-profile": {
|
||||
"message": "View profile"
|
||||
},
|
||||
"friends.heading": {
|
||||
"message": "Friends"
|
||||
},
|
||||
"friends.heading.active": {
|
||||
"message": "Active"
|
||||
},
|
||||
"friends.heading.offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"friends.heading.online": {
|
||||
"message": "Online"
|
||||
},
|
||||
"friends.heading.pending": {
|
||||
"message": "Pending"
|
||||
},
|
||||
"friends.no-friends-match": {
|
||||
"message": "No friends matching ''{query}''"
|
||||
},
|
||||
"friends.search-friends-placeholder": {
|
||||
"message": "Search friends..."
|
||||
},
|
||||
"friends.section.heading": {
|
||||
"message": "{title} - {count}"
|
||||
},
|
||||
"friends.sign-in-to-add-friends": {
|
||||
"message": "<link>Sign in to a Modrinth account</link> to add friends and see what they're playing!"
|
||||
},
|
||||
"instance.add-server.add-and-play": {
|
||||
"message": "Add and play"
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@ import { get_default_user, login as login_flow, users } from '@/helpers/auth'
|
||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { get as getSettings } from '@/helpers/settings.ts'
|
||||
import type { Cape, Skin } from '@/helpers/skins.ts'
|
||||
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
|
||||
import {
|
||||
equip_skin,
|
||||
filterDefaultSkins,
|
||||
@@ -245,16 +245,18 @@ function openUploadSkinModal(e: MouseEvent) {
|
||||
|
||||
function onSkinFileUploaded(buffer: ArrayBuffer) {
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
normalize_skin_texture(`data:image/png;base64,` + arrayBufferToBase64(buffer)).then(
|
||||
(skinTextureNormalized: Uint8Array) => {
|
||||
const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized)
|
||||
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
|
||||
editSkinModal.value.restoreWithNewTexture(skinTexUrl)
|
||||
} else {
|
||||
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer)
|
||||
normalize_skin_texture(originalSkinTexUrl).then((skinTextureNormalized: Uint8Array) => {
|
||||
const skinTexUrl: SkinTextureUrl = {
|
||||
original: originalSkinTexUrl,
|
||||
normalized: `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized),
|
||||
}
|
||||
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
|
||||
editSkinModal.value.restoreWithNewTexture(skinTexUrl)
|
||||
} else {
|
||||
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onUploadCanceled() {
|
||||
|
||||
@@ -139,9 +139,9 @@
|
||||
<template #add_content> <PlusIcon /> Add content </template>
|
||||
<template #edit> <EditIcon /> Edit </template>
|
||||
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
|
||||
<template #open_folder> <ClipboardCopyIcon /> Open folder </template>
|
||||
<template #open_folder> <FolderOpenIcon /> Open folder </template>
|
||||
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
|
||||
<template #open_link> <ClipboardCopyIcon /> Open in Modrinth <ExternalIcon /> </template>
|
||||
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
|
||||
<template #copy_names><EditIcon />Copy names</template>
|
||||
<template #copy_slugs><HashIcon />Copy slugs</template>
|
||||
<template #copy_links><GlobeIcon />Copy links</template>
|
||||
|
||||
@@ -92,7 +92,7 @@ import {
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button, Card } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
@@ -151,17 +151,26 @@ const expandImage = (item, index) => {
|
||||
|
||||
function keyListener(e) {
|
||||
if (expandedGalleryItem.value) {
|
||||
e.preventDefault()
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
hideImage()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
previousImage()
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
nextImage()
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keypress', keyListener)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', keyListener)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', keyListener)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
[package]
|
||||
name = "theseus_playground"
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
enumset = { workspace = true }
|
||||
theseus = { workspace = true, features = ["cli"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
enumset.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+41
-43
@@ -1,53 +1,51 @@
|
||||
[package]
|
||||
name = "theseus_gui"
|
||||
version = "1.0.0-local" # The actual version is set by the theseus-build workflow on tagging
|
||||
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
|
||||
license = "GPL-3.0-only"
|
||||
repository = "https://github.com/modrinth/code/apps/app/"
|
||||
# The actual version is set by the theseus-build workflow on tagging
|
||||
version = "1.0.0-local"
|
||||
edition.workspace = true
|
||||
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
|
||||
repository = "https://github.com/modrinth/code/apps/app/"
|
||||
license = "GPL-3.0-only"
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true }
|
||||
daedalus = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
either = { workspace = true }
|
||||
enumset = { workspace = true, features = ["serde"] }
|
||||
hyper = { workspace = true, features = ["server"] }
|
||||
hyper-util = { workspace = true }
|
||||
native-dialog = { workspace = true }
|
||||
paste = { workspace = true }
|
||||
path-util = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_with = { workspace = true }
|
||||
tauri = { workspace = true, features = [
|
||||
"devtools",
|
||||
"macos-private-api",
|
||||
"protocol-asset",
|
||||
] }
|
||||
tauri-plugin-deep-link = { workspace = true }
|
||||
tauri-plugin-dialog = { workspace = true }
|
||||
tauri-plugin-http = { workspace = true }
|
||||
tauri-plugin-opener = { workspace = true }
|
||||
tauri-plugin-os = { workspace = true }
|
||||
tauri-plugin-single-instance = { workspace = true }
|
||||
tauri-plugin-updater = { workspace = true }
|
||||
tauri-plugin-window-state = { workspace = true }
|
||||
theseus = { workspace = true, features = ["tauri"] }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-error = { workspace = true }
|
||||
url = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
uuid = { workspace = true, features = ["serde", "v4"] }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { workspace = true, features = ["codegen"] }
|
||||
|
||||
[dependencies]
|
||||
theseus = { workspace = true, features = ["tauri"] }
|
||||
path-util.workspace = true
|
||||
|
||||
serde_json.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_with.workspace = true
|
||||
|
||||
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] }
|
||||
tauri-plugin-deep-link.workspace = true
|
||||
tauri-plugin-dialog.workspace = true
|
||||
tauri-plugin-http.workspace = true
|
||||
tauri-plugin-opener.workspace = true
|
||||
tauri-plugin-os.workspace = true
|
||||
tauri-plugin-single-instance.workspace = true
|
||||
tauri-plugin-updater.workspace = true
|
||||
tauri-plugin-window-state.workspace = true
|
||||
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
thiserror.workspace = true
|
||||
daedalus.workspace = true
|
||||
chrono.workspace = true
|
||||
either.workspace = true
|
||||
hyper = { workspace = true, features = ["server"] }
|
||||
hyper-util.workspace = true
|
||||
|
||||
url.workspace = true
|
||||
urlencoding.workspace = true
|
||||
uuid = { workspace = true, features = ["serde", "v4"] }
|
||||
|
||||
tracing.workspace = true
|
||||
tracing-error.workspace = true
|
||||
|
||||
dashmap.workspace = true
|
||||
paste.workspace = true
|
||||
enumset = { workspace = true, features = ["serde"] }
|
||||
|
||||
native-dialog.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
tauri-plugin-updater = { workspace = true, optional = true }
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -272,7 +272,7 @@ pub struct EditProfile {
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "::serde_with::rust::double_option"
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub loader_version: Option<Option<String>>,
|
||||
|
||||
@@ -281,45 +281,45 @@ pub struct EditProfile {
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "::serde_with::rust::double_option"
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub linked_data: Option<Option<LinkedData>>,
|
||||
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "::serde_with::rust::double_option"
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub java_path: Option<Option<String>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "::serde_with::rust::double_option"
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub extra_launch_args: Option<Option<Vec<String>>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "::serde_with::rust::double_option"
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub custom_env_vars: Option<Option<Vec<(String, String)>>>,
|
||||
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "::serde_with::rust::double_option"
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub memory: Option<Option<MemorySettings>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "::serde_with::rust::double_option"
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub force_fullscreen: Option<Option<bool>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "::serde_with::rust::double_option"
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub game_resolution: Option<Option<WindowSize>>,
|
||||
pub hooks: Option<Hooks>,
|
||||
|
||||
@@ -155,7 +155,8 @@ fn main() {
|
||||
// Use *only* POSITION and SIZE state flags, because saving VISIBLE causes the `visible: false` to not take effect
|
||||
.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::POSITION
|
||||
| tauri_plugin_window_state::StateFlags::SIZE,
|
||||
| tauri_plugin_window_state::StateFlags::SIZE
|
||||
| tauri_plugin_window_state::StateFlags::MAXIMIZED,
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
[package]
|
||||
name = "daedalus_client"
|
||||
version = "0.2.2"
|
||||
authors = ["Jai A <jai@modrinth.com>"]
|
||||
edition.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
daedalus.workspace = true
|
||||
tokio = { workspace = true, features = ["sync", "macros", "rt-multi-thread"] }
|
||||
futures.workspace = true
|
||||
dotenvy.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
serde-xml-rs.workspace = true
|
||||
thiserror.workspace = true
|
||||
reqwest = { workspace = true, features = ["stream", "json", "rustls-tls-native-roots"] }
|
||||
async_zip = { workspace = true, features = [
|
||||
"chrono",
|
||||
"tokio-fs",
|
||||
"deflate",
|
||||
"bzip2",
|
||||
"zstd",
|
||||
"deflate64",
|
||||
"bzip2",
|
||||
"chrono",
|
||||
"deflate",
|
||||
"deflate64",
|
||||
"tokio-fs",
|
||||
"zstd",
|
||||
] }
|
||||
bytes = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
bytes.workspace = true
|
||||
rust-s3.workspace = true
|
||||
dashmap.workspace = true
|
||||
sha1_smol.workspace = true
|
||||
daedalus = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
indexmap = { workspace = true, features = ["serde"] }
|
||||
itertools.workspace = true
|
||||
tracing-error.workspace = true
|
||||
|
||||
tracing.workspace = true
|
||||
itertools = { workspace = true }
|
||||
reqwest = { workspace = true, features = [
|
||||
"json",
|
||||
"rustls-tls-native-roots",
|
||||
"stream",
|
||||
] }
|
||||
rust-s3 = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde-xml-rs = { workspace = true }
|
||||
sha1_smol = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-error = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM rust:1.89.0 AS build
|
||||
FROM rust:1.90.0 AS build
|
||||
|
||||
WORKDIR /usr/src/daedalus
|
||||
COPY . .
|
||||
@@ -14,7 +14,7 @@ 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:trixie-slim
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/modrinth/code
|
||||
LABEL org.opencontainers.image.title=daedalus
|
||||
|
||||
@@ -2762,10 +2762,7 @@
|
||||
},
|
||||
{
|
||||
"_comment": "Replace glfw from 3.3.1 with version from 3.3.2 to prevent stack smashing",
|
||||
"match": [
|
||||
"org.lwjgl:lwjgl-glfw-natives-linux:3.3.1",
|
||||
"org.lwjgl:lwjgl-glfw:3.3.1:natives-linux"
|
||||
],
|
||||
"match": ["org.lwjgl:lwjgl-glfw-natives-linux:3.3.1"],
|
||||
"override": {
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
@@ -2776,5 +2773,115 @@
|
||||
},
|
||||
"name": "org.lwjgl:lwjgl-glfw-natives-linux:3.3.2-lwjgl.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "Use newer JNA on macOS to prevent crashes due to faulty assertion",
|
||||
"match": [
|
||||
"net.java.dev.jna:jna:5.6.0",
|
||||
"net.java.dev.jna:jna:5.8.0",
|
||||
"net.java.dev.jna:jna:5.9.0",
|
||||
"net.java.dev.jna:jna:5.10.0",
|
||||
"net.java.dev.jna:jna:5.12.1"
|
||||
],
|
||||
"override": {
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow"
|
||||
},
|
||||
{
|
||||
"action": "disallow",
|
||||
"os": {
|
||||
"name": "osx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "disallow",
|
||||
"os": {
|
||||
"name": "osx-arm64"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"additionalLibraries": [
|
||||
{
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"sha1": "1200e7ebeedbe0d10062093f32925a912020e747",
|
||||
"size": 1879325,
|
||||
"url": "https://libraries.minecraft.net/net/java/dev/jna/jna/5.13.0/jna-5.13.0.jar"
|
||||
}
|
||||
},
|
||||
"name": "net.java.dev.jna:jna:5.13.0",
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow",
|
||||
"os": {
|
||||
"name": "osx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "allow",
|
||||
"os": {
|
||||
"name": "osx-arm64"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_comment": "Use newer JNA on macOS to prevent crashes due to faulty assertion",
|
||||
"match": [
|
||||
"net.java.dev.jna:jna-platform:5.6.0",
|
||||
"net.java.dev.jna:jna-platform:5.8.0",
|
||||
"net.java.dev.jna:jna-platform:5.9.0",
|
||||
"net.java.dev.jna:jna-platform:5.10.0",
|
||||
"net.java.dev.jna:jna-platform:5.12.1"
|
||||
],
|
||||
"override": {
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow"
|
||||
},
|
||||
{
|
||||
"action": "disallow",
|
||||
"os": {
|
||||
"name": "osx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "disallow",
|
||||
"os": {
|
||||
"name": "osx-arm64"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"additionalLibraries": [
|
||||
{
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"sha1": "88e9a306715e9379f3122415ef4ae759a352640d",
|
||||
"size": 1363209,
|
||||
"url": "https://libraries.minecraft.net/net/java/dev/jna/jna-platform/5.13.0/jna-platform-5.13.0.jar"
|
||||
}
|
||||
},
|
||||
"name": "net.java.dev.jna:jna-platform:5.13.0",
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow",
|
||||
"os": {
|
||||
"name": "osx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "allow",
|
||||
"os": {
|
||||
"name": "osx-arm64"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -186,6 +186,8 @@ pub struct LibraryPatch {
|
||||
}
|
||||
|
||||
fn fetch_library_patches() -> Result<Vec<LibraryPatch>, Error> {
|
||||
// The file below is a copy of https://github.com/PrismLauncher/meta/blob/main/meta/common/mojang-library-patches.json.
|
||||
// That file belongs to a repository licensed under the Microsoft Public License (Ms-PL)
|
||||
let patches = include_bytes!("../library-patches.json");
|
||||
Ok(serde_json::from_slice(patches)?)
|
||||
}
|
||||
|
||||
@@ -3875,6 +3875,13 @@ paths:
|
||||
/updates/{id|slug}/forge_updates.json:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ProjectIdentifier'
|
||||
- name: neoforge
|
||||
in: query
|
||||
description: Whether to include NeoForge versions. Can be `only` (NeoForge-only versions), `include` (both Forge and NeoForge versions), or omitted (Forge-only versions).
|
||||
schema:
|
||||
type: string
|
||||
enum: [only, include]
|
||||
example: include
|
||||
servers:
|
||||
- url: https://api.modrinth.com
|
||||
description: Production server
|
||||
@@ -3902,6 +3909,15 @@ paths:
|
||||
Make sure that the version format you use for your Modrinth releases is the same as the version format you use in your `mods.toml`.
|
||||
If you use a format such as `1.2.3-forge` or `1.2.3+1.19` with your Modrinth releases but your `mods.toml` only has `1.2.3`,
|
||||
the update checker may not function properly.
|
||||
|
||||
If you're using NeoForge, NeoForge versions will, by default, not appear in the default URL.
|
||||
You will need to add `?neoforge=only` to show your NeoForge-only versions, or `?neoforge=include` for both.
|
||||
|
||||
```toml
|
||||
[[mods]]
|
||||
# the other stuff here - ID, version, display name, etc.
|
||||
updateJSONURL = "https://api.modrinth.com/updates/{slug|ID}/forge_updates.json?neoforge=only"
|
||||
```
|
||||
tags:
|
||||
- misc
|
||||
responses:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Guide for contributing to Modrinth's backend
|
||||
|
||||
This project is part of our [monorepo](https://github.com/modrinth/code). You can find it in the `apps/labrinth` directory. The instructions below assume that you have switched your working directory to the `apps/labrinth` subdirectory.
|
||||
|
||||
[labrinth] is the Rust-based backend serving Modrinth's API with the help of the [Actix](https://actix.rs) framework. To get started with a labrinth instance, install docker, docker-compose (which comes with Docker), and [Rust]. The initial startup can be done simply with the command `docker-compose up`, or with `docker compose up` (Compose V2 and later). That will deploy a PostgreSQL database on port 5432, a MeiliSearch instance on port 7700, and a [Mailpit](https://mailpit.axllent.org/) SMTP server on port 1025, with a web UI to inspect sent emails on port 8025. To run the API itself, you'll need to use the `cargo run` command, this will deploy the API on port 8000.
|
||||
[labrinth] is the Rust-based backend serving Modrinth's API with the help of the [Actix](https://actix.rs) framework. To get started with a labrinth instance, install docker, docker-compose (which comes with Docker), cmake, and [Rust]. The initial startup can be done simply with the command `docker-compose up`, or with `docker compose up` (Compose V2 and later). That will deploy a PostgreSQL database on port 5432, a MeiliSearch instance on port 7700, and a [Mailpit](https://mailpit.axllent.org/) SMTP server on port 1025, with a web UI to inspect sent emails on port 8025. To run the API itself, you'll need to use the `cargo run` command, this will deploy the API on port 8000.
|
||||
|
||||
To get a basic configuration, copy the `.env.local` file to `.env`. Now, you'll have to install the sqlx CLI, which can be done with cargo:
|
||||
|
||||
|
||||
@@ -38,9 +38,9 @@ Once the user is ready to authorize your application, you need to construct a UR
|
||||
| `state` | A mechanism to prevent certain attacks. Explained further below. Recommended but optional |
|
||||
| `redirect_uri` | The URI the user is redirect to after finishing authorization |
|
||||
|
||||
You might have noticed the `state` parameter. [CSRF] (Cross-site request forgery), and [clickjacking] are security vulnerabilities that you're recommended to protect against. In OAuth2 this is usually done with the `state` parameter. When the user initiates a request to start authorization, you include a `state` which is unique to this request. This can, for example, be saved in localStorge or a cookie. When the redirect URI is called, you verify that the `state` parameter is the same. Using `state` is optional, but recommended.
|
||||
You might have noticed the `state` parameter. [CSRF] (Cross-site request forgery), and [clickjacking] are security vulnerabilities that you're recommended to protect against. In OAuth2 this is usually done with the `state` parameter. When the user initiates a request to start authorization, you include a `state` which is unique to this request. This can, for example, be saved in localStorage or a cookie. When the redirect URI is called, you verify that the `state` parameter is the same. Using `state` is optional, but recommended.
|
||||
|
||||
The scope identifiers are currently best found in the backend source code located at [`apps/labrinth/src/models/v3/pats.rs`]. The scope parameter is an array of scope identifiers, seperated by a plus sign (`+`).
|
||||
The scope identifiers are currently best found in the backend source code located at [`apps/labrinth/src/models/v3/pats.rs`]. The scope parameter is an array of scope identifiers, separated by a plus sign (`+`).
|
||||
|
||||
The redirect URI is the endpoint on your server that will receive the code which can eventually be used to act on the user's behalf. For security reasons the redirect URI used has to be allowlisted in your application settings. The redirect will contain the following query parameters:
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
::backdrop,
|
||||
:root[data-theme='light'],
|
||||
[data-theme='light'] ::backdrop {
|
||||
--sl-font-system:
|
||||
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
|
||||
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
--sl-font-system: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
|
||||
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
|
||||
--sl-color-white: var(--color-contrast); /* “white” */
|
||||
--sl-color-gray-1: var(--color-base);
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
<template>
|
||||
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
|
||||
<nuxt-link
|
||||
:to="flags.enableMedalPromotion ? '/servers?plan&ref=medal' : '/servers'"
|
||||
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
|
||||
<div class="wrapper relative mb-3 flex w-full justify-center rounded-2xl">
|
||||
<AutoLink
|
||||
:to="currentAd.link"
|
||||
:aria-label="currentAd.description"
|
||||
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit] bg-bg-raised"
|
||||
>
|
||||
<img
|
||||
:src="`https://cdn-raw.modrinth.com/${flags.enableMedalPromotion ? 'medal-modrinth-servers' : 'modrinth-servers-placeholder'}-light.webp`"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
:src="currentAd.light"
|
||||
aria-hidden="true"
|
||||
:alt="currentAd.description"
|
||||
class="light-image hidden rounded-[inherit]"
|
||||
/>
|
||||
<img
|
||||
:src="`https://cdn-raw.modrinth.com/${flags.enableMedalPromotion ? 'medal-modrinth-servers' : 'modrinth-servers-placeholder'}-dark.webp`"
|
||||
alt="Host your next server with Modrinth Servers"
|
||||
:src="currentAd.dark"
|
||||
aria-hidden="true"
|
||||
:alt="currentAd.description"
|
||||
class="dark-image rounded-[inherit]"
|
||||
/>
|
||||
</nuxt-link>
|
||||
</AutoLink>
|
||||
<div
|
||||
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
|
||||
>
|
||||
@@ -23,6 +26,8 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { AutoLink } from '@modrinth/ui'
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
useHead({
|
||||
@@ -55,6 +60,25 @@ useHead({
|
||||
],
|
||||
})
|
||||
|
||||
const AD_PRESETS = {
|
||||
medal: {
|
||||
light: 'https://cdn-raw.modrinth.com/medal-modrinth-servers-light-new.webp',
|
||||
dark: 'https://cdn-raw.modrinth.com/medal-modrinth-servers-dark-new.webp',
|
||||
description: 'Host your next server with Modrinth Servers',
|
||||
link: '/servers?plan&ref=medal',
|
||||
},
|
||||
'modrinth-servers': {
|
||||
light: 'https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp',
|
||||
dark: 'https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp',
|
||||
description: 'Host your next server with Modrinth Servers',
|
||||
link: '/servers',
|
||||
},
|
||||
}
|
||||
|
||||
const currentAd = computed(() =>
|
||||
flags.value.enableMedalPromotion ? AD_PRESETS.medal : AD_PRESETS['modrinth-servers'],
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
window.tude = window.tude || { cmd: [] }
|
||||
window.Raven = window.Raven || { cmd: [] }
|
||||
@@ -137,10 +161,14 @@ iframe[id^='google_ads_iframe'] {
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.ad-parent {
|
||||
.wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper > * {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
{{ analytics.error.value }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!isInitialized || analytics.loading.value" class="universal-card">
|
||||
<h2>
|
||||
<span class="label__title">Loading analytics...</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div v-else class="graphs">
|
||||
<div class="graphs__vertical-bar">
|
||||
<client-only>
|
||||
@@ -419,6 +424,7 @@ const isUsingProjectColors = computed({
|
||||
const startDate = ref(dayjs().startOf('day'))
|
||||
const endDate = ref(dayjs().endOf('day'))
|
||||
const timeResolution = ref(30)
|
||||
const isInitialized = ref(false)
|
||||
|
||||
onBeforeMount(() => {
|
||||
// Load cached data and range from localStorage - cache.
|
||||
@@ -449,6 +455,8 @@ onMounted(() => {
|
||||
startDate.value = ranges.startDate
|
||||
endDate.value = ranges.endDate
|
||||
timeResolution.value = selectedRange.value.timeResolution
|
||||
|
||||
isInitialized.value = true
|
||||
})
|
||||
|
||||
const internalRange: Ref<RangeObject> = ref(null as unknown as RangeObject)
|
||||
@@ -482,6 +490,7 @@ const analytics = useFetchAllAnalytics(
|
||||
startDate,
|
||||
endDate,
|
||||
timeResolution,
|
||||
isInitialized,
|
||||
)
|
||||
|
||||
const formattedCategorySubtitle = computed(() => {
|
||||
|
||||
@@ -631,6 +631,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeybinds)
|
||||
notifications.setNotificationLocation('right')
|
||||
})
|
||||
|
||||
|
||||
@@ -13,17 +13,26 @@ import {
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from '@modrinth/ui'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
ProgressBar,
|
||||
} from '@modrinth/ui'
|
||||
import type { Backup } from '@modrinth/utils'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'prepare' | 'download' | 'rename' | 'restore' | 'lock' | 'retry'): void
|
||||
(e: 'download' | 'rename' | 'restore' | 'lock' | 'retry'): void
|
||||
(e: 'delete', skipConfirmation?: boolean): void
|
||||
}>()
|
||||
|
||||
@@ -33,11 +42,13 @@ const props = withDefaults(
|
||||
preview?: boolean
|
||||
kyrosUrl?: string
|
||||
jwt?: string
|
||||
server?: ModrinthServer
|
||||
}>(),
|
||||
{
|
||||
preview: false,
|
||||
kyrosUrl: undefined,
|
||||
jwt: undefined,
|
||||
server: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -49,14 +60,8 @@ const backupQueued = computed(
|
||||
const automated = computed(() => props.backup.automated)
|
||||
const failedToCreate = computed(() => props.backup.interrupted)
|
||||
|
||||
const preparedDownloadStates = ['ready', 'done']
|
||||
const inactiveStates = ['failed', 'cancelled']
|
||||
|
||||
const hasPreparedDownload = computed(() => {
|
||||
const fileState = props.backup.task?.file?.state ?? ''
|
||||
return preparedDownloadStates.includes(fileState)
|
||||
})
|
||||
|
||||
const creating = computed(() => {
|
||||
const task = props.backup.task?.create
|
||||
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
|
||||
@@ -79,22 +84,7 @@ const restoring = computed(() => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
const initiatedPrepare = ref(false)
|
||||
|
||||
const preparingFile = computed(() => {
|
||||
if (hasPreparedDownload.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const task = props.backup.task?.file
|
||||
return (
|
||||
(!task && initiatedPrepare.value) ||
|
||||
(task && task.progress < 1 && !inactiveStates.includes(task.state))
|
||||
)
|
||||
})
|
||||
|
||||
const failedToRestore = computed(() => props.backup.task?.restore?.state === 'failed')
|
||||
const failedToPrepareFile = computed(() => props.backup.task?.file?.state === 'failed')
|
||||
|
||||
const messages = defineMessages({
|
||||
locked: {
|
||||
@@ -121,22 +111,6 @@ const messages = defineMessages({
|
||||
id: 'servers.backups.item.queued-for-backup',
|
||||
defaultMessage: 'Queued for backup',
|
||||
},
|
||||
preparingDownload: {
|
||||
id: 'servers.backups.item.preparing-download',
|
||||
defaultMessage: 'Preparing download...',
|
||||
},
|
||||
prepareDownload: {
|
||||
id: 'servers.backups.item.prepare-download',
|
||||
defaultMessage: 'Prepare download',
|
||||
},
|
||||
prepareDownloadAgain: {
|
||||
id: 'servers.backups.item.prepare-download-again',
|
||||
defaultMessage: 'Try preparing again',
|
||||
},
|
||||
alreadyPreparing: {
|
||||
id: 'servers.backups.item.already-preparing',
|
||||
defaultMessage: 'Already preparing backup for download',
|
||||
},
|
||||
creatingBackup: {
|
||||
id: 'servers.backups.item.creating-backup',
|
||||
defaultMessage: 'Creating backup...',
|
||||
@@ -153,10 +127,6 @@ const messages = defineMessages({
|
||||
id: 'servers.backups.item.failed-to-restore-backup',
|
||||
defaultMessage: 'Failed to restore from backup',
|
||||
},
|
||||
failedToPrepareFile: {
|
||||
id: 'servers.backups.item.failed-to-prepare-backup',
|
||||
defaultMessage: 'Failed to prepare download',
|
||||
},
|
||||
automated: {
|
||||
id: 'servers.backups.item.automated',
|
||||
defaultMessage: 'Automated',
|
||||
@@ -165,7 +135,48 @@ const messages = defineMessages({
|
||||
id: 'servers.backups.item.retry',
|
||||
defaultMessage: 'Retry',
|
||||
},
|
||||
downloadingBackup: {
|
||||
id: 'servers.backups.item.downloading-backup',
|
||||
defaultMessage: 'Downloading backup...',
|
||||
},
|
||||
downloading: {
|
||||
id: 'servers.backups.item.downloading',
|
||||
defaultMessage: 'Downloading',
|
||||
},
|
||||
})
|
||||
|
||||
const downloadingState = ref<{ progress: number; state: string } | undefined>(undefined)
|
||||
|
||||
const downloading = computed(() => downloadingState.value)
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!props.server?.backups || downloading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
downloadingState.value = { progress: 0, state: 'ongoing' }
|
||||
|
||||
try {
|
||||
const download = props.server.backups.downloadBackup(props.backup.id, props.backup.name)
|
||||
|
||||
download.onProgress((p) => {
|
||||
downloadingState.value = { progress: p.progress, state: 'ongoing' }
|
||||
})
|
||||
|
||||
await download.promise
|
||||
|
||||
emit('download')
|
||||
} catch (error) {
|
||||
console.error('Failed to download backup:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Download failed',
|
||||
text: error instanceof Error ? error.message : 'Failed to download backup',
|
||||
})
|
||||
} finally {
|
||||
downloadingState.value = undefined
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
@@ -200,17 +211,13 @@ const messages = defineMessages({
|
||||
</span>
|
||||
<span v-if="(failedToCreate || failedToRestore) && (automated || backup.locked)">•</span>
|
||||
<span
|
||||
v-if="failedToCreate || failedToRestore || failedToPrepareFile"
|
||||
v-if="failedToCreate || failedToRestore"
|
||||
class="flex items-center gap-1 text-sm text-red"
|
||||
>
|
||||
<XIcon />
|
||||
{{
|
||||
formatMessage(
|
||||
failedToCreate
|
||||
? messages.failedToCreateBackup
|
||||
: failedToRestore
|
||||
? messages.failedToRestoreBackup
|
||||
: messages.failedToPrepareFile,
|
||||
failedToCreate ? messages.failedToCreateBackup : messages.failedToRestoreBackup,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
@@ -237,6 +244,15 @@ const messages = defineMessages({
|
||||
class="max-w-full"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="downloading" class="col-span-2 flex flex-col gap-3 text-blue">
|
||||
{{ formatMessage(messages.downloadingBackup) }}
|
||||
<ProgressBar
|
||||
:progress="downloading.progress >= 0 ? downloading.progress : 0"
|
||||
color="blue"
|
||||
:waiting="downloading.progress <= 0"
|
||||
class="max-w-full"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="col-span-2">
|
||||
{{ dayjs(backup.created_at).format('MMMM D, YYYY [at] h:mm A') }}
|
||||
@@ -268,57 +284,32 @@ const messages = defineMessages({
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template v-else>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
v-if="hasPreparedDownload"
|
||||
:class="{
|
||||
disabled: !kyrosUrl || !jwt,
|
||||
}"
|
||||
:href="`https://${kyrosUrl}/modrinth/v0/backups/${backup.id}/download?auth=${jwt}`"
|
||||
@click="() => emit('download')"
|
||||
>
|
||||
<ButtonStyled v-show="!downloading">
|
||||
<button :disabled="!server?.backups" @click="handleDownload">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(commonMessages.downloadButton) }}
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
:disabled="!!preparingFile"
|
||||
@click="
|
||||
() => {
|
||||
initiatedPrepare = true
|
||||
emit('prepare')
|
||||
}
|
||||
"
|
||||
>
|
||||
<SpinnerIcon v-if="preparingFile" class="animate-spin" />
|
||||
<DownloadIcon v-else />
|
||||
{{
|
||||
formatMessage(
|
||||
preparingFile
|
||||
? messages.preparingDownload
|
||||
: failedToPrepareFile
|
||||
? messages.prepareDownloadAgain
|
||||
: messages.prepareDownload,
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{ id: 'rename', action: () => emit('rename') },
|
||||
{
|
||||
id: 'rename',
|
||||
action: () => emit('rename'),
|
||||
disabled: !!restoring || !!downloading,
|
||||
},
|
||||
{
|
||||
id: 'restore',
|
||||
action: () => emit('restore'),
|
||||
disabled: !!restoring || !!preparingFile,
|
||||
disabled: !!restoring || !!downloading,
|
||||
},
|
||||
{ id: 'lock', action: () => emit('lock') },
|
||||
{ id: 'lock', action: () => emit('lock'), disabled: !!restoring || !!downloading },
|
||||
{ divider: true },
|
||||
{
|
||||
id: 'delete',
|
||||
color: 'red',
|
||||
action: () => emit('delete'),
|
||||
disabled: !!restoring || !!preparingFile,
|
||||
disabled: !!restoring || !!downloading,
|
||||
},
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -68,7 +68,11 @@
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="standard" color="brand">
|
||||
<button :disabled="!canTakeAction" @click="handlePrimaryAction">
|
||||
<button
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:disabled="!canTakeAction"
|
||||
@click="handlePrimaryAction"
|
||||
>
|
||||
<div v-if="isTransitionState" class="grid place-content-center">
|
||||
<LoadingIcon />
|
||||
</div>
|
||||
@@ -122,12 +126,15 @@ import { useStorage } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
|
||||
|
||||
import LoadingIcon from './icons/LoadingIcon.vue'
|
||||
import PanelSpinner from './PanelSpinner.vue'
|
||||
import ServerInfoLabels from './ServerInfoLabels.vue'
|
||||
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
interface PowerAction {
|
||||
action: ServerPowerAction
|
||||
@@ -142,6 +149,7 @@ const props = defineProps<{
|
||||
serverName?: string
|
||||
serverData: object
|
||||
uptimeSeconds: number
|
||||
backupInProgress?: BackupInProgressReason
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -163,7 +171,11 @@ const dontAskAgain = ref(false)
|
||||
const startingDelay = ref(false)
|
||||
|
||||
const canTakeAction = computed(
|
||||
() => !props.isActioning && !startingDelay.value && !isTransitionState.value,
|
||||
() =>
|
||||
!props.isActioning &&
|
||||
!startingDelay.value &&
|
||||
!isTransitionState.value &&
|
||||
!props.backupInProgress,
|
||||
)
|
||||
const isRunning = computed(() => serverState.value === 'running')
|
||||
const isTransitionState = computed(() =>
|
||||
|
||||
@@ -158,11 +158,20 @@ const currentPlanFromSubscription = computed<ServerPlan | undefined>(() => {
|
||||
: undefined
|
||||
})
|
||||
|
||||
const currentInterval = computed(() => {
|
||||
const interval = subscription.value?.interval
|
||||
|
||||
if (interval === 'monthly' || interval === 'quarterly') {
|
||||
return interval
|
||||
}
|
||||
return 'monthly'
|
||||
})
|
||||
|
||||
async function initiatePayment(body: any): Promise<any> {
|
||||
if (subscription.value) {
|
||||
const transformedBody = {
|
||||
interval: body.charge?.interval,
|
||||
payment_method: body.id,
|
||||
payment_method: body.type === 'confirmation_token' ? body.token : body.id,
|
||||
product: body.charge?.product_id,
|
||||
region: body.metadata?.server_region,
|
||||
}
|
||||
@@ -247,7 +256,7 @@ async function open(id?: string) {
|
||||
subscription.value = null
|
||||
}
|
||||
|
||||
purchaseModal.value?.show('quarterly')
|
||||
purchaseModal.value?.show(currentInterval.value)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -11,12 +11,35 @@ export class BackupsModule extends ServerModule {
|
||||
}
|
||||
|
||||
async create(backupName: string): Promise<string> {
|
||||
const response = await useServersFetch<{ id: string }>(`servers/${this.serverId}/backups`, {
|
||||
method: 'POST',
|
||||
body: { name: backupName },
|
||||
})
|
||||
await this.fetch() // Refresh this module
|
||||
return response.id
|
||||
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substring(7)}`
|
||||
const tempBackup: Backup = {
|
||||
id: tempId,
|
||||
name: backupName,
|
||||
created_at: new Date().toISOString(),
|
||||
locked: false,
|
||||
automated: false,
|
||||
interrupted: false,
|
||||
ongoing: true,
|
||||
task: { create: { progress: 0, state: 'ongoing' } },
|
||||
}
|
||||
this.data.push(tempBackup)
|
||||
|
||||
try {
|
||||
const response = await useServersFetch<{ id: string }>(`servers/${this.serverId}/backups`, {
|
||||
method: 'POST',
|
||||
body: { name: backupName },
|
||||
})
|
||||
|
||||
const backup = this.data.find((b) => b.id === tempId)
|
||||
if (backup) {
|
||||
backup.id = response.id
|
||||
}
|
||||
|
||||
return response.id
|
||||
} catch (error) {
|
||||
this.data = this.data.filter((b) => b.id !== tempId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async rename(backupId: string, newName: string): Promise<void> {
|
||||
@@ -24,41 +47,47 @@ export class BackupsModule extends ServerModule {
|
||||
method: 'POST',
|
||||
body: { name: newName },
|
||||
})
|
||||
await this.fetch() // Refresh this module
|
||||
await this.fetch()
|
||||
}
|
||||
|
||||
async delete(backupId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await this.fetch() // Refresh this module
|
||||
await this.fetch()
|
||||
}
|
||||
|
||||
async restore(backupId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/restore`, {
|
||||
method: 'POST',
|
||||
})
|
||||
await this.fetch() // Refresh this module
|
||||
}
|
||||
const backup = this.data.find((b) => b.id === backupId)
|
||||
if (backup) {
|
||||
if (!backup.task) backup.task = {}
|
||||
backup.task.restore = { progress: 0, state: 'ongoing' }
|
||||
}
|
||||
|
||||
async prepare(backupId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/prepare-download`, {
|
||||
method: 'POST',
|
||||
})
|
||||
try {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/restore`, {
|
||||
method: 'POST',
|
||||
})
|
||||
} catch (error) {
|
||||
if (backup?.task?.restore) {
|
||||
delete backup.task.restore
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async lock(backupId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/lock`, {
|
||||
method: 'POST',
|
||||
})
|
||||
await this.fetch() // Refresh this module
|
||||
await this.fetch()
|
||||
}
|
||||
|
||||
async unlock(backupId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/unlock`, {
|
||||
method: 'POST',
|
||||
})
|
||||
await this.fetch() // Refresh this module
|
||||
await this.fetch()
|
||||
}
|
||||
|
||||
async retry(backupId: string): Promise<void> {
|
||||
@@ -77,4 +106,87 @@ export class BackupsModule extends ServerModule {
|
||||
async getAutoBackup(): Promise<AutoBackupSettings> {
|
||||
return await useServersFetch(`servers/${this.serverId}/autobackup`)
|
||||
}
|
||||
|
||||
downloadBackup(
|
||||
backupId: string,
|
||||
backupName: string,
|
||||
): {
|
||||
promise: Promise<void>
|
||||
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => void
|
||||
cancel: () => void
|
||||
} {
|
||||
const progressSubject = new EventTarget()
|
||||
const abortController = new AbortController()
|
||||
|
||||
const downloadPromise = new Promise<void>((resolve, reject) => {
|
||||
const auth = this.server.general?.node
|
||||
if (!auth?.instance || !auth?.token) {
|
||||
reject(new Error('Missing authentication credentials'))
|
||||
return
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const progress = e.loaded / e.total
|
||||
progressSubject.dispatchEvent(
|
||||
new CustomEvent('progress', {
|
||||
detail: { loaded: e.loaded, total: e.total, progress },
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
// progress = -1 to indicate indeterminate size
|
||||
progressSubject.dispatchEvent(
|
||||
new CustomEvent('progress', {
|
||||
detail: { loaded: e.loaded, total: 0, progress: -1 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const blob = xhr.response
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${backupName}.zip`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
resolve()
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Download failed with status ${xhr.status}`))
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onerror = () => reject(new Error('Download failed'))
|
||||
xhr.onabort = () => reject(new Error('Download cancelled'))
|
||||
|
||||
xhr.open(
|
||||
'GET',
|
||||
`https://${auth.instance}/modrinth/v0/backups/${backupId}/download?auth=${auth.token}`,
|
||||
)
|
||||
xhr.responseType = 'blob'
|
||||
xhr.send()
|
||||
|
||||
abortController.signal.addEventListener('abort', () => xhr.abort())
|
||||
})
|
||||
|
||||
return {
|
||||
promise: downloadPromise,
|
||||
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => {
|
||||
progressSubject.addEventListener('progress', ((e: CustomEvent) => {
|
||||
cb(e.detail)
|
||||
}) as EventListener)
|
||||
},
|
||||
cancel: () => abortController.abort(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface ServersFetchOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
contentType?: string
|
||||
body?: Record<string, any>
|
||||
version?: number
|
||||
version?: number | 'internal'
|
||||
override?: {
|
||||
url?: string
|
||||
token?: string
|
||||
@@ -30,7 +30,7 @@ export async function useServersFetch<T>(
|
||||
'[Modrinth Servers] Cannot fetch without auth',
|
||||
10000,
|
||||
)
|
||||
throw new ModrinthServerError('Missing auth token', 401, error, module)
|
||||
throw new ModrinthServerError('Missing auth token', 401, error, module, undefined, undefined)
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -52,7 +52,14 @@ export async function useServersFetch<T>(
|
||||
'[Modrinth Servers] Circuit breaker open - too many recent failures',
|
||||
503,
|
||||
)
|
||||
throw new ModrinthServerError('Service temporarily unavailable', 503, error, module)
|
||||
throw new ModrinthServerError(
|
||||
'Service temporarily unavailable',
|
||||
503,
|
||||
error,
|
||||
module,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
|
||||
if (now - lastFailureTime.value > 30000) {
|
||||
@@ -69,7 +76,14 @@ export async function useServersFetch<T>(
|
||||
'[Modrinth Servers] Cannot fetch without base url. Make sure to set a PYRO_BASE_URL in environment variables',
|
||||
10001,
|
||||
)
|
||||
throw new ModrinthServerError('Configuration error: Missing PYRO_BASE_URL', 500, error, module)
|
||||
throw new ModrinthServerError(
|
||||
'Configuration error: Missing PYRO_BASE_URL',
|
||||
500,
|
||||
error,
|
||||
module,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
|
||||
const versionString = `v${version}`
|
||||
@@ -82,7 +96,9 @@ export async function useServersFetch<T>(
|
||||
? `https://${newOverrideUrl}/${path.replace(/^\//, '')}`
|
||||
: version === 0
|
||||
? `${base}/modrinth/v${version}/${path.replace(/^\//, '')}`
|
||||
: `${base}/v${version}/${path.replace(/^\//, '')}`
|
||||
: version === 'internal'
|
||||
? `${base}/_internal/${path.replace(/^\//, '')}`
|
||||
: `${base}/v${version}/${path.replace(/^\//, '')}`
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'Modrinth/1.0 (https://modrinth.com)',
|
||||
@@ -177,6 +193,7 @@ export async function useServersFetch<T>(
|
||||
fetchError,
|
||||
module,
|
||||
v1Error,
|
||||
error.data,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -198,6 +215,8 @@ export async function useServersFetch<T>(
|
||||
undefined,
|
||||
fetchError,
|
||||
module,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -210,7 +229,14 @@ export async function useServersFetch<T>(
|
||||
statusCode,
|
||||
lastError,
|
||||
)
|
||||
throw new ModrinthServerError('Maximum retry attempts reached', statusCode, pyroError, module)
|
||||
throw new ModrinthServerError(
|
||||
'Maximum retry attempts reached',
|
||||
statusCode,
|
||||
pyroError,
|
||||
module,
|
||||
undefined,
|
||||
lastError.data,
|
||||
)
|
||||
}
|
||||
|
||||
const fetchError = new ModrinthServersFetchError(
|
||||
@@ -218,5 +244,12 @@ export async function useServersFetch<T>(
|
||||
undefined,
|
||||
lastError || undefined,
|
||||
)
|
||||
throw new ModrinthServerError('Maximum retry attempts reached', undefined, fetchError, module)
|
||||
throw new ModrinthServerError(
|
||||
'Maximum retry attempts reached',
|
||||
undefined,
|
||||
fetchError,
|
||||
module,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,6 +82,23 @@
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
<PagewideBanner v-if="showTinMismatchBanner" variant="error">
|
||||
<template #title>
|
||||
<span>{{ formatMessage(tinMismatchBannerMessages.title) }}</span>
|
||||
</template>
|
||||
<template #description>
|
||||
<span>{{ formatMessage(tinMismatchBannerMessages.description) }}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex w-fit flex-row">
|
||||
<ButtonStyled color="red">
|
||||
<nuxt-link to="https://support.modrinth.com" target="_blank" rel="noopener">
|
||||
<MessageIcon /> {{ formatMessage(tinMismatchBannerMessages.action) }}
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
<PagewideBanner v-if="showTaxComplianceBanner" variant="warning">
|
||||
<template #title>
|
||||
<span>{{ formatMessage(taxBannerMessages.title) }}</span>
|
||||
@@ -444,6 +461,12 @@
|
||||
link: '/admin/servers/notices',
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'servers-nodes',
|
||||
color: 'primary',
|
||||
link: '/admin/servers/nodes',
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<ModrinthIcon aria-hidden="true" />
|
||||
@@ -463,6 +486,7 @@
|
||||
<template #servers-notices>
|
||||
<IssuesIcon aria-hidden="true" /> {{ formatMessage(messages.manageServerNotices) }}
|
||||
</template>
|
||||
<template #servers-nodes> <ServerIcon aria-hidden="true" /> Server Nodes </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
@@ -851,6 +875,7 @@ import {
|
||||
LogInIcon,
|
||||
LogOutIcon,
|
||||
MastodonIcon,
|
||||
MessageIcon,
|
||||
ModrinthIcon,
|
||||
MoonIcon,
|
||||
OrganizationIcon,
|
||||
@@ -918,7 +943,15 @@ const showTaxComplianceBanner = computed(() => {
|
||||
const thresholdMet = (bal.withdrawn_ytd ?? 0) >= 600
|
||||
const status = bal.form_completion_status ?? 'unknown'
|
||||
const isComplete = status === 'complete'
|
||||
return !!auth.value.user && thresholdMet && !isComplete
|
||||
const isTinMismatch = status === 'tin-mismatch'
|
||||
return !!auth.value.user && thresholdMet && !isComplete && !isTinMismatch
|
||||
})
|
||||
|
||||
const showTinMismatchBanner = computed(() => {
|
||||
const bal = payoutBalance.value
|
||||
if (!bal) return false
|
||||
const status = bal.form_completion_status ?? 'unknown'
|
||||
return !!auth.value.user && status === 'tin-mismatch'
|
||||
})
|
||||
|
||||
const taxBannerMessages = defineMessages({
|
||||
@@ -929,7 +962,7 @@ const taxBannerMessages = defineMessages({
|
||||
description: {
|
||||
id: 'layout.banner.tax.description',
|
||||
defaultMessage:
|
||||
'You’ve already withdrawn over $600 from Modrinth this year. To comply with tax regulations, you need to complete a tax form. Your withdrawals are paused until this form is submitted.',
|
||||
"You've already withdrawn over $600 from Modrinth this year. To comply with tax regulations, you need to complete a tax form. Your withdrawals are paused until this form is submitted.",
|
||||
},
|
||||
action: {
|
||||
id: 'layout.banner.tax.action',
|
||||
@@ -937,6 +970,22 @@ const taxBannerMessages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const tinMismatchBannerMessages = defineMessages({
|
||||
title: {
|
||||
id: 'layout.banner.tin-mismatch.title',
|
||||
defaultMessage: 'Tax form failed',
|
||||
},
|
||||
description: {
|
||||
id: 'layout.banner.tin-mismatch.description',
|
||||
defaultMessage:
|
||||
"Your withdrawals are temporarily locked because your TIN or SSN didn't match IRS records. Please contact support to reset and resubmit your tax form.",
|
||||
},
|
||||
action: {
|
||||
id: 'layout.banner.tin-mismatch.action',
|
||||
defaultMessage: 'Contact support',
|
||||
},
|
||||
})
|
||||
|
||||
const taxFormModalRef = ref(null)
|
||||
function openTaxForm(e) {
|
||||
if (taxFormModalRef.value && taxFormModalRef.value.startTaxForm) {
|
||||
@@ -1732,6 +1781,7 @@ const footerLinks = [
|
||||
@media screen and (min-width: 354px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 674px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
@@ -1926,10 +1976,12 @@ const footerLinks = [
|
||||
width: 25rem;
|
||||
height: 25rem;
|
||||
}
|
||||
|
||||
.animation-ring-2 {
|
||||
width: 50rem;
|
||||
height: 50rem;
|
||||
}
|
||||
|
||||
.animation-ring-3 {
|
||||
width: 100rem;
|
||||
height: 100rem;
|
||||
@@ -1958,15 +2010,19 @@ const footerLinks = [
|
||||
0% {
|
||||
rotate: 0deg;
|
||||
}
|
||||
|
||||
25% {
|
||||
rotate: calc(1deg * (var(--_r-count) - 20));
|
||||
}
|
||||
|
||||
50% {
|
||||
rotate: 0deg;
|
||||
}
|
||||
|
||||
75% {
|
||||
rotate: calc(-1deg * (var(--_r-count) - 20));
|
||||
}
|
||||
|
||||
100% {
|
||||
rotate: 0deg;
|
||||
}
|
||||
@@ -1976,15 +2032,19 @@ const footerLinks = [
|
||||
0% {
|
||||
translate: 0;
|
||||
}
|
||||
|
||||
25% {
|
||||
translate: calc(2px * (var(--_r-count) - 20));
|
||||
}
|
||||
|
||||
50% {
|
||||
translate: 0;
|
||||
}
|
||||
|
||||
75% {
|
||||
translate: calc(-2px * (var(--_r-count) - 20));
|
||||
}
|
||||
|
||||
100% {
|
||||
translate: 0;
|
||||
}
|
||||
@@ -1994,15 +2054,19 @@ const footerLinks = [
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateY(calc(2px * (var(--_r-count) - 20)));
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateY(calc(-2px * (var(--_r-count) - 20)));
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@@ -924,11 +924,20 @@
|
||||
"message": "Complete tax form"
|
||||
},
|
||||
"layout.banner.tax.description": {
|
||||
"message": "You’ve already withdrawn over $600 from Modrinth this year. To comply with tax regulations, you need to complete a tax form. Your withdrawals are paused until this form is submitted."
|
||||
"message": "You've already withdrawn over $600 from Modrinth this year. To comply with tax regulations, you need to complete a tax form. Your withdrawals are paused until this form is submitted."
|
||||
},
|
||||
"layout.banner.tax.title": {
|
||||
"message": "Tax form required"
|
||||
},
|
||||
"layout.banner.tin-mismatch.action": {
|
||||
"message": "Contact support"
|
||||
},
|
||||
"layout.banner.tin-mismatch.description": {
|
||||
"message": "Your withdrawals are temporarily locked because your TIN or SSN didn't match IRS records. Please contact support to reset and resubmit your tax form."
|
||||
},
|
||||
"layout.banner.tin-mismatch.title": {
|
||||
"message": "Tax form failed"
|
||||
},
|
||||
"layout.banner.verify-email.action": {
|
||||
"message": "Re-send verification email"
|
||||
},
|
||||
@@ -1103,27 +1112,66 @@
|
||||
"moderation.technical.search.placeholder": {
|
||||
"message": "Search tech reviews..."
|
||||
},
|
||||
"profile.bio.fallback.creator": {
|
||||
"message": "A Modrinth creator."
|
||||
},
|
||||
"profile.bio.fallback.user": {
|
||||
"message": "A Modrinth user."
|
||||
},
|
||||
"profile.button.billing": {
|
||||
"message": "Manage user billing"
|
||||
},
|
||||
"profile.button.edit-role": {
|
||||
"message": "Edit role"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"message": "View user details"
|
||||
},
|
||||
"profile.button.manage-projects": {
|
||||
"message": "Manage projects"
|
||||
},
|
||||
"profile.details.label.auth-providers": {
|
||||
"message": "Auth providers"
|
||||
},
|
||||
"profile.details.label.email": {
|
||||
"message": "Email"
|
||||
},
|
||||
"profile.details.label.has-password": {
|
||||
"message": "Has password"
|
||||
},
|
||||
"profile.details.label.has-totp": {
|
||||
"message": "Has TOTP"
|
||||
},
|
||||
"profile.details.label.payment-methods": {
|
||||
"message": "Payment methods"
|
||||
},
|
||||
"profile.details.tooltip.email-not-verified": {
|
||||
"message": "Email not verified"
|
||||
},
|
||||
"profile.details.tooltip.email-verified": {
|
||||
"message": "Email verified"
|
||||
},
|
||||
"profile.error.not-found": {
|
||||
"message": "User not found"
|
||||
},
|
||||
"profile.joined-at": {
|
||||
"message": "Joined <date>{ago}</date>"
|
||||
},
|
||||
"profile.label.badges": {
|
||||
"message": "Badges"
|
||||
},
|
||||
"profile.label.collection": {
|
||||
"message": "Collection"
|
||||
},
|
||||
"profile.label.details": {
|
||||
"message": "Details"
|
||||
},
|
||||
"profile.label.downloads": {
|
||||
"message": "{count} {count, plural, one {download} other {downloads}}"
|
||||
},
|
||||
"profile.label.joined": {
|
||||
"message": "Joined"
|
||||
},
|
||||
"profile.label.no": {
|
||||
"message": "No"
|
||||
},
|
||||
"profile.label.no-collections": {
|
||||
"message": "This user has no collections!"
|
||||
},
|
||||
@@ -1139,18 +1187,21 @@
|
||||
"profile.label.organizations": {
|
||||
"message": "Organizations"
|
||||
},
|
||||
"profile.label.projects": {
|
||||
"message": "{count} {count, plural, one {project} other {projects}}"
|
||||
},
|
||||
"profile.label.saving": {
|
||||
"message": "Saving..."
|
||||
},
|
||||
"profile.label.yes": {
|
||||
"message": "Yes"
|
||||
},
|
||||
"profile.meta.description": {
|
||||
"message": "Download {username}'s projects on Modrinth"
|
||||
},
|
||||
"profile.meta.description-with-bio": {
|
||||
"message": "{bio} - Download {username}'s projects on Modrinth"
|
||||
},
|
||||
"profile.stats.downloads": {
|
||||
"message": "{count, plural, one {<stat>{count}</stat> project download} other {<stat>{count}</stat> project downloads}}"
|
||||
},
|
||||
"profile.stats.projects": {
|
||||
"message": "{count, plural, one {<stat>{count}</stat> project} other {<stat>{count}</stat> projects}}"
|
||||
},
|
||||
"profile.stats.projects-followers": {
|
||||
"message": "{count, plural, one {<stat>{count}</stat> project follower} other {<stat>{count}</stat> project followers}}"
|
||||
},
|
||||
@@ -1877,21 +1928,21 @@
|
||||
"servers.backup.restore.in-progress.tooltip": {
|
||||
"message": "Backup restore in progress"
|
||||
},
|
||||
"servers.backups.item.already-preparing": {
|
||||
"message": "Already preparing backup for download"
|
||||
},
|
||||
"servers.backups.item.automated": {
|
||||
"message": "Automated"
|
||||
},
|
||||
"servers.backups.item.creating-backup": {
|
||||
"message": "Creating backup..."
|
||||
},
|
||||
"servers.backups.item.downloading": {
|
||||
"message": "Downloading"
|
||||
},
|
||||
"servers.backups.item.downloading-backup": {
|
||||
"message": "Downloading backup..."
|
||||
},
|
||||
"servers.backups.item.failed-to-create-backup": {
|
||||
"message": "Failed to create backup"
|
||||
},
|
||||
"servers.backups.item.failed-to-prepare-backup": {
|
||||
"message": "Failed to prepare download"
|
||||
},
|
||||
"servers.backups.item.failed-to-restore-backup": {
|
||||
"message": "Failed to restore from backup"
|
||||
},
|
||||
@@ -1901,15 +1952,6 @@
|
||||
"servers.backups.item.locked": {
|
||||
"message": "Locked"
|
||||
},
|
||||
"servers.backups.item.prepare-download": {
|
||||
"message": "Prepare download"
|
||||
},
|
||||
"servers.backups.item.prepare-download-again": {
|
||||
"message": "Try preparing again"
|
||||
},
|
||||
"servers.backups.item.preparing-download": {
|
||||
"message": "Preparing download..."
|
||||
},
|
||||
"servers.backups.item.queued-for-backup": {
|
||||
"message": "Queued for backup"
|
||||
},
|
||||
|
||||
@@ -97,6 +97,41 @@
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<NewModal ref="creditModal">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">Credit subscription</span>
|
||||
</template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="days" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">Days to credit</span>
|
||||
<span>Enter the number of days to add to the next due date.</span>
|
||||
</label>
|
||||
<input id="days" v-model.number="creditDays" type="number" min="1" autocomplete="off" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="sendEmail" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">Send email to user</span>
|
||||
<span>Notify the user about the credited days.</span>
|
||||
</label>
|
||||
<Toggle id="sendEmail" v-model="creditSendEmail" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="crediting" @click="applyCredit">
|
||||
<CheckIcon aria-hidden="true" />
|
||||
Apply credit
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="creditModal.hide()">
|
||||
<XIcon aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<div class="page experimental-styles-within">
|
||||
<div
|
||||
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
|
||||
@@ -140,6 +175,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="subscription.metadata?.id" class="flex flex-col items-end gap-2">
|
||||
<CopyCode :text="subscription.metadata.id" />
|
||||
<ButtonStyled
|
||||
v-if="
|
||||
subscription.metadata?.type === 'pyro' || subscription.metadata?.type === 'medal'
|
||||
@@ -153,7 +189,12 @@
|
||||
<ServerIcon /> Server panel <ExternalIcon class="h-4 w-4" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<CopyCode :text="subscription.metadata.id" />
|
||||
<ButtonStyled>
|
||||
<button @click="showCreditModal(subscription)">
|
||||
<CurrencyIcon />
|
||||
Credit
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -292,6 +333,7 @@ import {
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import { formatCategory, formatPrice } from '@modrinth/utils'
|
||||
import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
|
||||
@@ -370,6 +412,11 @@ const modifying = ref(false)
|
||||
const modifyModal = ref()
|
||||
const cancel = ref(false)
|
||||
|
||||
const crediting = ref(false)
|
||||
const creditModal = ref()
|
||||
const creditDays = ref(7)
|
||||
const creditSendEmail = ref(true)
|
||||
|
||||
function showRefundModal(charge) {
|
||||
selectedCharge.value = charge
|
||||
refundType.value = 'full'
|
||||
@@ -385,6 +432,44 @@ function showModifyModal(charge, subscription) {
|
||||
modifyModal.value.show()
|
||||
}
|
||||
|
||||
function showCreditModal(subscription) {
|
||||
selectedSubscription.value = subscription
|
||||
creditDays.value = 1
|
||||
creditSendEmail.value = true
|
||||
creditModal.value.show()
|
||||
}
|
||||
|
||||
async function applyCredit() {
|
||||
crediting.value = true
|
||||
try {
|
||||
const daysParsed = Math.max(1, Math.floor(Number(creditDays.value) || 1))
|
||||
await useBaseFetch('billing/credit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
subscription_ids: [selectedSubscription.value.id],
|
||||
days: daysParsed,
|
||||
send_email: creditSendEmail.value,
|
||||
message: DEFAULT_CREDIT_EMAIL_MESSAGE,
|
||||
}),
|
||||
internal: true,
|
||||
})
|
||||
addNotification({
|
||||
title: 'Credit applied',
|
||||
text: 'The subscription due date has been updated.',
|
||||
type: 'success',
|
||||
})
|
||||
await refreshCharges()
|
||||
creditModal.value.hide()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'Error applying credit',
|
||||
text: err.data?.description ?? String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
crediting.value = false
|
||||
}
|
||||
|
||||
async function refundCharge() {
|
||||
refunding.value = true
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="page experimental-styles-within">
|
||||
<div
|
||||
class="mb-6 flex items-end justify-between border-0 border-b border-solid border-divider pb-4"
|
||||
>
|
||||
<h1 class="m-0 text-2xl">Server nodes</h1>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="openBatchModal"><PlusIcon /> Batch credit</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<NewModal ref="batchModal">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">Batch credit</span>
|
||||
</template>
|
||||
<div class="flex w-[720px] max-w-[90vw] flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Type </span>
|
||||
<span>Select target to credit.</span>
|
||||
</label>
|
||||
<TeleportDropdownMenu
|
||||
v-model="mode"
|
||||
:options="modeOptions"
|
||||
:display-name="(x) => x.name"
|
||||
name="Type"
|
||||
class="max-w-[8rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="days" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Days to credit </span>
|
||||
</label>
|
||||
<input
|
||||
id="days"
|
||||
v-model.number="days"
|
||||
class="w-32"
|
||||
type="number"
|
||||
min="1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="mode.id === 'nodes'" class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="node-input" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Node hostnames </span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="node-input"
|
||||
v-model="nodeInput"
|
||||
class="w-32"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<ButtonStyled color="blue" color-fill="text">
|
||||
<button class="shrink-0" @click="addNode">
|
||||
<PlusIcon />
|
||||
Add
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="selectedNodes.length" class="mt-1 flex flex-wrap gap-2">
|
||||
<TagItem v-for="h in selectedNodes" :key="`node-${h}`" :action="() => removeNode(h)">
|
||||
<XIcon />
|
||||
{{ h }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="region-select" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Region </span>
|
||||
<span>This will credit all active servers in the region.</span>
|
||||
</label>
|
||||
<TeleportDropdownMenu
|
||||
id="region-select"
|
||||
v-model="selectedRegion"
|
||||
:options="regions"
|
||||
:display-name="(x) => x.display"
|
||||
name="Region"
|
||||
class="max-w-[24rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="between flex items-center gap-4">
|
||||
<label for="send-email-nodes" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Send email </span>
|
||||
</label>
|
||||
<Toggle id="send-email-nodes" v-model="sendEmail" />
|
||||
</div>
|
||||
|
||||
<div v-if="sendEmail" class="flex flex-col gap-2">
|
||||
<label for="message-region" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Customize Email </span>
|
||||
<span>
|
||||
Unless a particularly bad or out of the ordinary event happened, keep this to the
|
||||
default
|
||||
</span>
|
||||
</label>
|
||||
<div class="text-muted rounded-lg border border-divider bg-button-bg p-4">
|
||||
<p>Hi {user.name},</p>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="message-region"
|
||||
v-model="message"
|
||||
rows="3"
|
||||
class="w-full overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
To make up for it, we've added {{ days }} day{{ pluralize(days) }} to your Modrinth
|
||||
Servers subscription.
|
||||
</p>
|
||||
<p>
|
||||
Your next charge was scheduled for {credit.previous_due} and will now be on
|
||||
{credit.next_due}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="applyDisabled" @click="apply">
|
||||
<CheckIcon aria-hidden="true" />
|
||||
Apply credits
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="batchModal?.hide?.()">
|
||||
<XIcon aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
TagItem,
|
||||
TeleportDropdownMenu,
|
||||
Toggle,
|
||||
} from '@modrinth/ui'
|
||||
import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useBaseFetch } from '#imports'
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const batchModal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const days = ref(1)
|
||||
const sendEmail = ref(true)
|
||||
const message = ref('')
|
||||
|
||||
const modeOptions = [
|
||||
{ id: 'nodes', name: 'Nodes' },
|
||||
{ id: 'region', name: 'Region' },
|
||||
]
|
||||
const mode = ref(modeOptions[0])
|
||||
|
||||
const nodeInput = ref('')
|
||||
const selectedNodes = ref<string[]>([])
|
||||
|
||||
type RegionOpt = { key: string; display: string }
|
||||
const regions = ref<RegionOpt[]>([])
|
||||
const selectedRegion = ref<RegionOpt | null>(null)
|
||||
const nodeHostnames = ref<string[]>([])
|
||||
|
||||
function openBatchModal() {
|
||||
void ensureOverview()
|
||||
|
||||
message.value = DEFAULT_CREDIT_EMAIL_MESSAGE
|
||||
batchModal.value?.show()
|
||||
}
|
||||
|
||||
function addNode() {
|
||||
const v = nodeInput.value.trim()
|
||||
if (!v) return
|
||||
if (!nodeHostnames.value.includes(v)) {
|
||||
addNotification({
|
||||
title: 'Unknown node',
|
||||
text: "This hostname doesn't exist",
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!selectedNodes.value.includes(v)) selectedNodes.value.push(v)
|
||||
nodeInput.value = ''
|
||||
}
|
||||
|
||||
function removeNode(v: string) {
|
||||
selectedNodes.value = selectedNodes.value.filter((x) => x !== v)
|
||||
}
|
||||
|
||||
const applyDisabled = computed(() => {
|
||||
if (days.value < 1) return true
|
||||
if (mode.value.id === 'nodes') return selectedNodes.value.length === 0
|
||||
return !selectedRegion.value
|
||||
})
|
||||
|
||||
async function ensureOverview() {
|
||||
if (regions.value.length || nodeHostnames.value.length) return
|
||||
try {
|
||||
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
|
||||
regions.value = (data.regions || []).map((r: any) => ({
|
||||
key: r.key,
|
||||
display: `${r.display_name} (${r.key})`,
|
||||
}))
|
||||
nodeHostnames.value = data.node_hostnames || []
|
||||
if (!selectedRegion.value && regions.value.length) selectedRegion.value = regions.value[0]
|
||||
} catch (err) {
|
||||
addNotification({ title: 'Failed to load nodes overview', text: String(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
try {
|
||||
const body =
|
||||
mode.value.id === 'nodes'
|
||||
? {
|
||||
nodes: selectedNodes.value.slice(),
|
||||
days: Math.max(1, Math.floor(days.value)),
|
||||
send_email: sendEmail.value,
|
||||
message: message.value?.trim() || DEFAULT_CREDIT_EMAIL_MESSAGE,
|
||||
}
|
||||
: {
|
||||
region: selectedRegion.value!.key,
|
||||
days: Math.max(1, Math.floor(days.value)),
|
||||
send_email: sendEmail.value,
|
||||
message: message.value?.trim() || DEFAULT_CREDIT_EMAIL_MESSAGE,
|
||||
}
|
||||
await useBaseFetch('billing/credit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
internal: true,
|
||||
})
|
||||
addNotification({ title: 'Credits applied', type: 'success' })
|
||||
batchModal.value?.hide()
|
||||
selectedNodes.value = []
|
||||
nodeInput.value = ''
|
||||
message.value = ''
|
||||
} catch (err: any) {
|
||||
addNotification({
|
||||
title: 'Error applying credits',
|
||||
text: err?.data?.description ?? String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function pluralize(n: number): string {
|
||||
return n === 1 ? '' : 's'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 1rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 56rem;
|
||||
}
|
||||
</style>
|
||||
@@ -18,12 +18,17 @@
|
||||
<section class="auth-form">
|
||||
<p>{{ formatMessage(postVerificationMessages.description) }}</p>
|
||||
|
||||
<NuxtLink v-if="auth.user" class="btn" link="/settings/account">
|
||||
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
|
||||
</NuxtLink>
|
||||
<NuxtLink v-else to="/auth/sign-in" class="btn btn-primary continue-btn centered-btn">
|
||||
{{ formatMessage(messages.signIn) }} <RightArrowIcon />
|
||||
</NuxtLink>
|
||||
<ButtonStyled v-if="auth.user">
|
||||
<NuxtLink to="/settings/account">
|
||||
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<NuxtLink to="/auth/sign-in">
|
||||
{{ formatMessage(messages.signIn) }}
|
||||
<RightArrowIcon />
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -40,24 +45,26 @@
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<button
|
||||
v-if="auth.user"
|
||||
class="btn btn-primary continue-btn"
|
||||
@click="handleResendEmailVerification"
|
||||
>
|
||||
{{ formatMessage(failedVerificationMessages.action) }} <RightArrowIcon />
|
||||
</button>
|
||||
<ButtonStyled v-if="auth.user" color="brand">
|
||||
<button @click="handleResendEmailVerification">
|
||||
{{ formatMessage(failedVerificationMessages.action) }}
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<NuxtLink v-else to="/auth/sign-in" class="btn btn-primary continue-btn centered-btn">
|
||||
{{ formatMessage(messages.signIn) }} <RightArrowIcon />
|
||||
</NuxtLink>
|
||||
<ButtonStyled v-else color="brand">
|
||||
<NuxtLink to="/auth/sign-in">
|
||||
{{ formatMessage(messages.signIn) }}
|
||||
<RightArrowIcon />
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { RightArrowIcon, SettingsIcon } from '@modrinth/assets'
|
||||
import { injectNotificationManager } from '@modrinth/ui'
|
||||
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
before proceeding.
|
||||
</p>
|
||||
|
||||
<p v-if="blockedByTax" class="font-bold text-orange">
|
||||
<p v-else-if="blockedByTax" class="font-bold text-orange">
|
||||
You have withdrawn over $600 this year. To continue withdrawing, you must complete a tax form.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { articles as rawArticles } from '@modrinth/blog'
|
||||
import { Avatar, ButtonStyled } from '@modrinth/ui'
|
||||
import type { User } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import NewsletterButton from '~/components/ui/NewsletterButton.vue'
|
||||
import ShareArticleButtons from '~/components/ui/ShareArticleButtons.vue'
|
||||
@@ -74,6 +74,36 @@ useSeoMeta({
|
||||
twitterCard: 'summary_large_image',
|
||||
twitterImage: () => thumbnailPath.value,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const videos = document.querySelectorAll('.markdown-body video')
|
||||
|
||||
if ('IntersectionObserver' in window) {
|
||||
const videoObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const video = entry.target as HTMLVideoElement
|
||||
if (entry.isIntersecting) {
|
||||
video.play().catch(() => {})
|
||||
} else {
|
||||
video.pause()
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
threshold: 0.5,
|
||||
},
|
||||
)
|
||||
|
||||
videos.forEach((video) => {
|
||||
videoObserver.observe(video)
|
||||
})
|
||||
} else {
|
||||
videos.forEach((video) => {
|
||||
;(video as HTMLVideoElement).setAttribute('autoplay', '')
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -181,14 +211,19 @@ useSeoMeta({
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ul,
|
||||
ul > li:not(:last-child),
|
||||
ol > li:not(:last-child) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
p {
|
||||
> li > p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
> li > p:not(:last-child) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -220,20 +255,22 @@ useSeoMeta({
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-top: 1.5rem;
|
||||
@media (min-width: 640px) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.125rem;
|
||||
font-size: 1rem;
|
||||
margin-top: 1.25rem;
|
||||
@media (min-width: 640px) {
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
@media (min-width: 640px) {
|
||||
font-size: 1rem;
|
||||
@@ -275,8 +312,34 @@ useSeoMeta({
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background-color: var(--color-divider);
|
||||
}
|
||||
|
||||
.video-wrapper {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-button-border);
|
||||
@media (min-width: 640px) {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
object-fit: cover;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
> img,
|
||||
> :has(img:first-child:last-child) {
|
||||
> .video-wrapper,
|
||||
> :has(img:first-child:last-child),
|
||||
> :has(video:first-child:last-child) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
:server-name="serverData.name"
|
||||
:server-data="serverData"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:backup-in-progress="backupInProgress"
|
||||
@action="sendPowerAction"
|
||||
/>
|
||||
</div>
|
||||
@@ -354,7 +355,7 @@
|
||||
>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Server data</h2>
|
||||
<pre class="markdown-body w-full overflow-auto rounded-2xl bg-bg-raised p-4 text-sm">{{
|
||||
JSON.stringify(server, null, ' ')
|
||||
JSON.stringify(server, null, ' ')
|
||||
}}</pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -759,9 +760,14 @@ const handleWebSocketMessage = (data: WSEvent) => {
|
||||
curBackup.task = {}
|
||||
}
|
||||
|
||||
curBackup.task[data.task] = {
|
||||
progress: data.progress,
|
||||
state: data.state,
|
||||
const currentState = curBackup.task[data.task]?.state
|
||||
const shouldUpdate = !(currentState === 'ongoing' && data.state === 'unchanged')
|
||||
|
||||
if (shouldUpdate) {
|
||||
curBackup.task[data.task] = {
|
||||
progress: data.progress,
|
||||
state: data.state,
|
||||
}
|
||||
}
|
||||
|
||||
curBackup.ongoing = data.task === 'create' && data.state === 'ongoing'
|
||||
@@ -1037,7 +1043,10 @@ const nodeUnavailableDetails = computed(() => [
|
||||
},
|
||||
{
|
||||
label: 'Node',
|
||||
value: server.general?.datacenter ?? 'Unknown',
|
||||
value:
|
||||
server.moduleErrors?.general?.error.responseData?.hostname ??
|
||||
server.general?.datacenter ??
|
||||
'Unknown',
|
||||
type: 'inline' as const,
|
||||
},
|
||||
{
|
||||
@@ -1277,6 +1286,7 @@ useHead({
|
||||
opacity: 0;
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
|
||||
@@ -105,9 +105,9 @@
|
||||
v-for="backup in backups"
|
||||
:key="`backup-${backup.id}`"
|
||||
:backup="backup"
|
||||
:server="props.server"
|
||||
:kyros-url="props.server.general?.node.instance"
|
||||
:jwt="props.server.general?.node.token"
|
||||
@prepare="() => prepareDownload(backup.id)"
|
||||
@download="() => triggerDownloadAnimation()"
|
||||
@rename="() => renameBackupModal?.show(backup)"
|
||||
@restore="() => restoreBackupModal?.show(backup)"
|
||||
@@ -233,19 +233,6 @@ function triggerDownloadAnimation() {
|
||||
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
|
||||
}
|
||||
|
||||
const prepareDownload = async (backupId: string) => {
|
||||
try {
|
||||
await props.server.backups?.prepare(backupId)
|
||||
} catch (error) {
|
||||
console.error('Failed to prepare download:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to prepare backup for download',
|
||||
text: error as string,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const lockBackup = async (backupId: string) => {
|
||||
try {
|
||||
await props.server.backups?.lock(backupId)
|
||||
|
||||
@@ -2,13 +2,52 @@
|
||||
<div v-if="user" class="experimental-styles-within">
|
||||
<ModalCreation ref="modal_creation" />
|
||||
<CollectionCreateModal ref="modal_collection_creation" />
|
||||
<NewModal ref="editRoleModal" header="Edit role">
|
||||
<div class="flex w-80 flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<TeleportDropdownMenu
|
||||
v-model="selectedRole"
|
||||
:options="roleOptions"
|
||||
name="edit-role"
|
||||
placeholder="Select a role"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="cancelRoleEdit">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="!selectedRole || selectedRole === user.role || isSavingRole"
|
||||
@click="saveRoleEdit"
|
||||
>
|
||||
<template v-if="isSavingRole">
|
||||
<SpinnerIcon class="animate-spin" /> {{ formatMessage(messages.savingLabel) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<SaveIcon /> {{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<NewModal v-if="auth.user && isStaff(auth.user)" ref="userDetailsModal" header="User details">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary">Email</span>
|
||||
<span class="text-lg font-bold text-primary">{{
|
||||
formatMessage(messages.emailLabel)
|
||||
}}</span>
|
||||
<div>
|
||||
<span
|
||||
v-tooltip="user.email_verified ? 'Email verified' : 'Email not verified'"
|
||||
v-tooltip="
|
||||
user.email_verified
|
||||
? formatMessage(messages.emailVerifiedTooltip)
|
||||
: formatMessage(messages.emailNotVerifiedTooltip)
|
||||
"
|
||||
class="flex w-fit items-center gap-1"
|
||||
>
|
||||
<span>{{ user.email }}</span>
|
||||
@@ -19,12 +58,16 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Auth providers </span>
|
||||
<span class="text-lg font-bold text-primary">{{
|
||||
formatMessage(messages.authProvidersLabel)
|
||||
}}</span>
|
||||
<span>{{ user.auth_providers.join(', ') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Payment methods</span>
|
||||
<span class="text-lg font-bold text-primary">{{
|
||||
formatMessage(messages.paymentMethodsLabel)
|
||||
}}</span>
|
||||
<span>
|
||||
<template v-if="user.payout_data?.paypal_address">
|
||||
Paypal ({{ user.payout_data.paypal_address }} - {{ user.payout_data.paypal_country }})
|
||||
@@ -39,16 +82,22 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Has password </span>
|
||||
<span class="text-lg font-bold text-primary">{{
|
||||
formatMessage(messages.hasPasswordLabel)
|
||||
}}</span>
|
||||
<span>
|
||||
{{ user.has_password ? 'Yes' : 'No' }}
|
||||
{{
|
||||
user.has_password ? formatMessage(messages.yesLabel) : formatMessage(messages.noLabel)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold text-primary"> Has TOTP </span>
|
||||
<span class="text-lg font-bold text-primary">{{
|
||||
formatMessage(messages.hasTotpLabel)
|
||||
}}</span>
|
||||
<span>
|
||||
{{ user.has_totp ? 'Yes' : 'No' }}
|
||||
{{ user.has_totp ? formatMessage(messages.yesLabel) : formatMessage(messages.noLabel) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,8 +116,8 @@
|
||||
user.bio
|
||||
? user.bio
|
||||
: projects.length === 0
|
||||
? 'A Modrinth user.'
|
||||
: 'A Modrinth creator.'
|
||||
? formatMessage(messages.bioFallbackUser)
|
||||
: formatMessage(messages.bioFallbackCreator)
|
||||
}}
|
||||
</template>
|
||||
<template #stats>
|
||||
@@ -76,16 +125,22 @@
|
||||
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
|
||||
>
|
||||
<BoxIcon class="h-6 w-6 text-secondary" />
|
||||
{{ formatCompactNumber(projects?.length || 0) }}
|
||||
projects
|
||||
{{
|
||||
formatMessage(messages.profileProjectsLabel, {
|
||||
count: formatCompactNumber(projects?.length || 0),
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="sumDownloads.toLocaleString()"
|
||||
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
|
||||
>
|
||||
<DownloadIcon class="h-6 w-6 text-secondary" />
|
||||
{{ formatCompactNumber(sumDownloads) }}
|
||||
downloads
|
||||
{{
|
||||
formatMessage(messages.profileDownloadsLabel, {
|
||||
count: formatCompactNumber(sumDownloads),
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="
|
||||
@@ -97,7 +152,7 @@
|
||||
class="flex items-center gap-2 font-semibold"
|
||||
>
|
||||
<CalendarIcon class="h-6 w-6 text-secondary" />
|
||||
Joined
|
||||
{{ formatMessage(messages.profileJoinedLabel) }}
|
||||
{{ formatRelativeTime(user.created) }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -127,6 +182,10 @@
|
||||
},
|
||||
{ id: 'copy-id', action: () => copyId() },
|
||||
{ id: 'copy-permalink', action: () => copyPermalink() },
|
||||
{
|
||||
divider: true,
|
||||
shown: auth.user && isAdmin(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'open-billing',
|
||||
action: () => navigateTo(`/admin/billing/${user.id}`),
|
||||
@@ -137,6 +196,11 @@
|
||||
action: () => $refs.userDetailsModal.show(),
|
||||
shown: auth.user && isStaff(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'edit-role',
|
||||
action: () => openRoleEditModal(),
|
||||
shown: auth.user && isAdmin(auth.user),
|
||||
},
|
||||
]"
|
||||
aria-label="More options"
|
||||
>
|
||||
@@ -165,6 +229,10 @@
|
||||
<InfoIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.infoButton) }}
|
||||
</template>
|
||||
<template #edit-role>
|
||||
<EditIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.editRoleButton) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
@@ -243,7 +311,7 @@
|
||||
<h2 class="title">{{ collection.name }}</h2>
|
||||
<div class="stats">
|
||||
<LibraryIcon aria-hidden="true" />
|
||||
Collection
|
||||
{{ formatMessage(messages.collectionLabel) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,25 +322,27 @@
|
||||
<div class="stats">
|
||||
<BoxIcon />
|
||||
{{
|
||||
`${$formatNumber(collection.projects?.length || 0, false)} project${(collection.projects?.length || 0) !== 1 ? 's' : ''}`
|
||||
`${$formatNumber(collection.projects?.length || 0, false)} project${
|
||||
(collection.projects?.length || 0) !== 1 ? 's' : ''
|
||||
}`
|
||||
}}
|
||||
</div>
|
||||
<div class="stats">
|
||||
<template v-if="collection.status === 'listed'">
|
||||
<GlobeIcon />
|
||||
<span> Public </span>
|
||||
<span> {{ formatMessage(commonMessages.publicLabel) }} </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'unlisted'">
|
||||
<LinkIcon />
|
||||
<span> Unlisted </span>
|
||||
<span> {{ formatMessage(commonMessages.unlistedLabel) }} </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'private'">
|
||||
<LockIcon />
|
||||
<span> Private </span>
|
||||
<span> {{ formatMessage(commonMessages.privateLabel) }} </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'rejected'">
|
||||
<XIcon />
|
||||
<span> Rejected </span>
|
||||
<span> {{ formatMessage(commonMessages.rejectedLabel) }} </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,6 +425,8 @@ import {
|
||||
LockIcon,
|
||||
MoreVerticalIcon,
|
||||
ReportIcon,
|
||||
SaveIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
@@ -362,10 +434,13 @@ import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
ContentPageHeader,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
OverflowMenu,
|
||||
TeleportDropdownMenu,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import { isAdmin } from '@modrinth/utils'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
|
||||
import TenMClubBadge from '~/assets/images/badges/10m-club.svg?component'
|
||||
@@ -398,26 +473,78 @@ const formatCompactNumber = useCompactNumber(true)
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
profileProjectsStats: {
|
||||
id: 'profile.stats.projects',
|
||||
defaultMessage:
|
||||
'{count, plural, one {<stat>{count}</stat> project} other {<stat>{count}</stat> projects}}',
|
||||
profileProjectsLabel: {
|
||||
id: 'profile.label.projects',
|
||||
defaultMessage: '{count} {count, plural, one {project} other {projects}}',
|
||||
},
|
||||
profileDownloadsStats: {
|
||||
id: 'profile.stats.downloads',
|
||||
defaultMessage:
|
||||
'{count, plural, one {<stat>{count}</stat> project download} other {<stat>{count}</stat> project downloads}}',
|
||||
profileDownloadsLabel: {
|
||||
id: 'profile.label.downloads',
|
||||
defaultMessage: '{count} {count, plural, one {download} other {downloads}}',
|
||||
},
|
||||
profileJoinedLabel: {
|
||||
id: 'profile.label.joined',
|
||||
defaultMessage: 'Joined',
|
||||
},
|
||||
savingLabel: {
|
||||
id: 'profile.label.saving',
|
||||
defaultMessage: 'Saving...',
|
||||
},
|
||||
emailLabel: {
|
||||
id: 'profile.details.label.email',
|
||||
defaultMessage: 'Email',
|
||||
},
|
||||
emailVerifiedTooltip: {
|
||||
id: 'profile.details.tooltip.email-verified',
|
||||
defaultMessage: 'Email verified',
|
||||
},
|
||||
emailNotVerifiedTooltip: {
|
||||
id: 'profile.details.tooltip.email-not-verified',
|
||||
defaultMessage: 'Email not verified',
|
||||
},
|
||||
authProvidersLabel: {
|
||||
id: 'profile.details.label.auth-providers',
|
||||
defaultMessage: 'Auth providers',
|
||||
},
|
||||
paymentMethodsLabel: {
|
||||
id: 'profile.details.label.payment-methods',
|
||||
defaultMessage: 'Payment methods',
|
||||
},
|
||||
hasPasswordLabel: {
|
||||
id: 'profile.details.label.has-password',
|
||||
defaultMessage: 'Has password',
|
||||
},
|
||||
hasTotpLabel: {
|
||||
id: 'profile.details.label.has-totp',
|
||||
defaultMessage: 'Has TOTP',
|
||||
},
|
||||
yesLabel: {
|
||||
id: 'profile.label.yes',
|
||||
defaultMessage: 'Yes',
|
||||
},
|
||||
noLabel: {
|
||||
id: 'profile.label.no',
|
||||
defaultMessage: 'No',
|
||||
},
|
||||
bioFallbackUser: {
|
||||
id: 'profile.bio.fallback.user',
|
||||
defaultMessage: 'A Modrinth user.',
|
||||
},
|
||||
bioFallbackCreator: {
|
||||
id: 'profile.bio.fallback.creator',
|
||||
defaultMessage: 'A Modrinth creator.',
|
||||
},
|
||||
collectionLabel: {
|
||||
id: 'profile.label.collection',
|
||||
defaultMessage: 'Collection',
|
||||
},
|
||||
profileProjectsFollowersStats: {
|
||||
id: 'profile.stats.projects-followers',
|
||||
defaultMessage:
|
||||
'{count, plural, one {<stat>{count}</stat> project follower} other {<stat>{count}</stat> project followers}}',
|
||||
},
|
||||
profileJoinedAt: {
|
||||
id: 'profile.joined-at',
|
||||
defaultMessage: 'Joined <date>{ago}</date>',
|
||||
},
|
||||
profileUserId: {
|
||||
id: 'profile.user-id',
|
||||
defaultMessage: 'User ID: {id}',
|
||||
@@ -472,6 +599,10 @@ const messages = defineMessages({
|
||||
id: 'profile.button.info',
|
||||
defaultMessage: 'View user details',
|
||||
},
|
||||
editRoleButton: {
|
||||
id: 'profile.button.edit-role',
|
||||
defaultMessage: 'Edit role',
|
||||
},
|
||||
userNotFoundError: {
|
||||
id: 'profile.error.not-found',
|
||||
defaultMessage: 'User not found',
|
||||
@@ -648,6 +779,55 @@ const navLinks = computed(() => [
|
||||
.slice()
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
])
|
||||
|
||||
const selectedRole = ref(user.value.role)
|
||||
const isSavingRole = ref(false)
|
||||
|
||||
const roleOptions = ['developer', 'moderator', 'admin']
|
||||
|
||||
const editRoleModal = useTemplateRef('editRoleModal')
|
||||
|
||||
const openRoleEditModal = () => {
|
||||
selectedRole.value = user.value.role
|
||||
editRoleModal.value?.show()
|
||||
}
|
||||
|
||||
const cancelRoleEdit = () => {
|
||||
selectedRole.value = user.value.role
|
||||
editRoleModal.value?.hide()
|
||||
}
|
||||
|
||||
function saveRoleEdit() {
|
||||
if (!selectedRole.value || selectedRole.value === user.value.role) {
|
||||
return
|
||||
}
|
||||
|
||||
isSavingRole.value = true
|
||||
|
||||
useBaseFetch(`user/${user.value.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
role: selectedRole.value,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
user.value.role = selectedRole.value
|
||||
|
||||
editRoleModal.value?.hide()
|
||||
})
|
||||
.catch(() => {
|
||||
console.error('Failed to update user role:', error)
|
||||
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to update role',
|
||||
message: 'An error occurred while updating the user role. Please try again.',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
isSavingRole.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
export default defineNuxtComponent({
|
||||
|
||||
@@ -475,3 +475,6 @@ pubmatic.com, 157097, RESELLER, 5d62403b186f2ace
|
||||
conversantmedia.com, 40881, RESELLER, 03113cd04947736d
|
||||
rubiconproject.com, 16928, RESELLER, 0bfd66d529a55807
|
||||
smaato.com, 1100053415, RESELLER, 07bcf65f187117b4
|
||||
|
||||
#Triplelift
|
||||
triplelift.com, 14661, RESELLER, 6c33edb13117fd86
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
Binary file not shown.
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"articles": [
|
||||
{
|
||||
"title": "More Ways to Withdraw",
|
||||
"summary": "Coming soon: new withdraw options and a redesigned revenue dashboard",
|
||||
"thumbnail": "https://modrinth.com/news/article/creator-withdrawals-overhaul/thumbnail.webp",
|
||||
"date": "2025-10-27T23:30:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/creator-withdrawals-overhaul"
|
||||
},
|
||||
{
|
||||
"title": "Standing By Our Values",
|
||||
"summary": "Keeping LGBTQIA+ content visible despite demands from Russia.",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -33,7 +33,6 @@
|
||||
}
|
||||
|
||||
#modrinth-rail-1 {
|
||||
border-radius: 1rem;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
@@ -90,10 +90,10 @@ import StyledDoc from '../shared/StyledDoc.vue'
|
||||
Purpose of Payment
|
||||
</Text>
|
||||
<Text class="m-0 text-sm leading-relaxed text-secondary">
|
||||
This payout reflects revenue earned by the creator through their activity on the Modrinth
|
||||
platform. Earnings are based on advertising revenue, subscriptions, and/or affiliate
|
||||
commissions tied to the creator's published projects, in accordance with the Rewards Program
|
||||
Terms.
|
||||
This payout reflects the creator's earnings from their activity on the Modrinth platform.
|
||||
Such earnings are based on advertising revenue derived from user engagement with the
|
||||
creator's published projects and/or affiliate commissions in accordance with the Rewards
|
||||
Program Terms.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
Modrinth account.
|
||||
</Text>
|
||||
<Text class="text-muted text-base">
|
||||
If you did not make this change, please contact us immediately by replying to this email or
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline"
|
||||
>through our Support Portal</VLink
|
||||
If you did not make this change, please contact us immediately through our
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
|
||||
>.
|
||||
</Text>
|
||||
</StyledEmail>
|
||||
|
||||
@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
sign in to your Modrinth account.
|
||||
</Text>
|
||||
<Text class="text-muted text-base">
|
||||
If you did not make this change, please contact us immediately by replying to this email or
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline"
|
||||
>through our Support Portal</VLink
|
||||
If you did not make this change, please contact us immediately through our
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
|
||||
>.
|
||||
</Text>
|
||||
</StyledEmail>
|
||||
|
||||
@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
{emailchanged.new_email}.
|
||||
</Text>
|
||||
<Text class="text-muted text-base">
|
||||
If you did not make this change, please contact us immediately by replying to this email or
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline"
|
||||
>through our Support Portal</VLink
|
||||
If you did not make this change, please contact us immediately through our
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
|
||||
>.
|
||||
</Text>
|
||||
</StyledEmail>
|
||||
|
||||
@@ -70,9 +70,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
|
||||
<Text class="text-muted text-base">
|
||||
If this wasn't you, please update your password and review your account security settings. If
|
||||
you cannot do this, contact us immediately by replying to this email or
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline"
|
||||
>through our Support Portal</VLink
|
||||
you cannot do this, contact us immediately through our
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
|
||||
>.
|
||||
</Text>
|
||||
</StyledEmail>
|
||||
|
||||
@@ -18,9 +18,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
A new personal access token, <b>{newpat.token_name}</b>, has been added to your account.
|
||||
</Text>
|
||||
<Text class="text-muted text-base">
|
||||
If you did not create this token, please contact us immediately by replying to this email or
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline"
|
||||
>through our Support Portal</VLink
|
||||
If you did not create this token, please contact us immediately through our
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
|
||||
>.
|
||||
</Text>
|
||||
</StyledEmail>
|
||||
|
||||
@@ -14,9 +14,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
<Text class="text-muted text-base">Hi {user.name},</Text>
|
||||
<Text class="text-muted text-base"> Your password has been changed on your account. </Text>
|
||||
<Text class="text-muted text-base">
|
||||
If you did not make this change, please contact us immediately by replying to this email or
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline"
|
||||
>through our Support Portal</VLink
|
||||
If you did not make this change, please contact us immediately through our
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
|
||||
>.
|
||||
</Text>
|
||||
</StyledEmail>
|
||||
|
||||
@@ -18,9 +18,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
Modrinth account.</Text
|
||||
>
|
||||
<Text class="text-muted text-base">
|
||||
If you did not make this change, please contact us immediately by replying to this email or
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline"
|
||||
>through our Support Portal</VLink
|
||||
If you did not make this change, please contact us immediately through our
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
|
||||
>.
|
||||
</Text>
|
||||
</StyledEmail>
|
||||
|
||||
@@ -34,8 +34,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
<Text class="text-base">
|
||||
If you have any questions about the creator rewards program, please contact support through
|
||||
the
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink>
|
||||
or by replying to this email.
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
|
||||
>.
|
||||
</Text>
|
||||
|
||||
<Text class="text-base">Thank you for being a creator on Modrinth!</Text>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { Heading, Text } from '@vue-email/components'
|
||||
|
||||
import StyledEmail from '../shared/StyledEmail.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StyledEmail title="We’ve added time to your server">
|
||||
<Heading as="h1" class="mb-2 text-2xl font-bold">We’ve added time to your server</Heading>
|
||||
|
||||
<Text class="text-muted text-base">Hi {user.name},</Text>
|
||||
<Text class="text-muted text-base">{credit.header_message}</Text>
|
||||
|
||||
<Text class="text-muted text-base">
|
||||
To make up for it, we've added {credit.days_formatted} to your {credit.subscription.type}
|
||||
subscription.
|
||||
</Text>
|
||||
<Text class="text-muted text-base">
|
||||
Your next charge was scheduled for {credit.previous_due} and will now be on {credit.next_due}.
|
||||
</Text>
|
||||
|
||||
<Text class="text-muted text-base">Thank you for supporting us,</Text>
|
||||
<Text class="text-muted text-base">The Modrinth Team</Text>
|
||||
</StyledEmail>
|
||||
</template>
|
||||
@@ -32,7 +32,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
|
||||
<Text class="text-muted text-base">
|
||||
Thank you for choosing Modrinth! If you have any questions or need help with your
|
||||
subscription, reply to this email or visit our
|
||||
subscription, visit our
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
|
||||
>.
|
||||
</Text>
|
||||
|
||||
@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
need to submit the code generated by your authenticator app.
|
||||
</Text>
|
||||
<Text class="text-muted text-base">
|
||||
If you did not make this change, please contact us immediately by replying to this email or
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline"
|
||||
>through our Support Portal</VLink
|
||||
If you did not make this change, please contact us immediately through our
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
|
||||
>.
|
||||
</Text>
|
||||
</StyledEmail>
|
||||
|
||||
@@ -18,9 +18,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
At your request, we've removed two-factor authentication from your Modrinth account.
|
||||
</Text>
|
||||
<Text class="text-muted text-base">
|
||||
If you did not make this change, please contact us immediately by replying to this email or
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline"
|
||||
>through our Support Portal</VLink
|
||||
If you did not make this change, please contact us immediately through our
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
|
||||
>.
|
||||
</Text>
|
||||
</StyledEmail>
|
||||
|
||||
@@ -18,6 +18,7 @@ export default {
|
||||
|
||||
// Subscriptions
|
||||
'subscription-tax-change': () => import('./account/SubscriptionTaxChange.vue'),
|
||||
'subscription-credited': () => import('./account/SubscriptionCredited.vue'),
|
||||
|
||||
// Moderation
|
||||
'report-submitted': () => import('./moderation/ReportSubmitted.vue'),
|
||||
|
||||
@@ -68,8 +68,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
in error or is abusive, please contact support
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">
|
||||
through the Support Portal</VLink
|
||||
>
|
||||
or by replying to this email.
|
||||
>.
|
||||
</Text>
|
||||
</StyledEmail>
|
||||
</template>
|
||||
|
||||
@@ -63,8 +63,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
this was sent in error or is abusive, please contact support
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">
|
||||
through the Support Portal</VLink
|
||||
>
|
||||
or by replying to this email.
|
||||
>.
|
||||
</Text>
|
||||
</StyledEmail>
|
||||
</template>
|
||||
|
||||
@@ -45,8 +45,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
</Button>
|
||||
|
||||
<Text class="text-base">
|
||||
If you have questions or believe something isn't correct, you can reply to this email or reach
|
||||
out via the
|
||||
If you have questions or believe something isn't correct, you can reach out via the
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
|
||||
>.
|
||||
</Text>
|
||||
|
||||
@@ -53,8 +53,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
<Text class="text-base">
|
||||
If you believe this status was applied in error, you can reply in the moderation thread or
|
||||
contact support through our
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink>
|
||||
or by replying to this email.
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
|
||||
>.
|
||||
</Text>
|
||||
|
||||
<Text class="text-base">Thank you for publishing on Modrinth!</Text>
|
||||
|
||||
@@ -53,8 +53,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
|
||||
<Text class="text-base">
|
||||
If you did not initiate this transfer, please contact support immediately through the
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink>
|
||||
or by replying to this email.
|
||||
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
|
||||
>.
|
||||
</Text>
|
||||
</StyledEmail>
|
||||
</template>
|
||||
|
||||
@@ -307,6 +307,7 @@ export const useFetchAllAnalytics = (
|
||||
startDate = ref(dayjs().subtract(30, 'days')),
|
||||
endDate = ref(dayjs()),
|
||||
timeResolution = ref(1440),
|
||||
isInitialized = ref(false),
|
||||
) => {
|
||||
const downloadData = ref(null)
|
||||
const viewData = ref(null)
|
||||
@@ -388,8 +389,18 @@ export const useFetchAllAnalytics = (
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => startDate.value, () => endDate.value, () => timeResolution.value, () => projects.value],
|
||||
[
|
||||
() => startDate.value,
|
||||
() => endDate.value,
|
||||
() => timeResolution.value,
|
||||
() => projects.value,
|
||||
() => isInitialized.value,
|
||||
],
|
||||
async () => {
|
||||
if (!isInitialized.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const q = {
|
||||
start_date: startDate.value.toISOString(),
|
||||
end_date: endDate.value.toISOString(),
|
||||
@@ -456,5 +467,6 @@ export const useFetchAllAnalytics = (
|
||||
totalData,
|
||||
loading,
|
||||
error,
|
||||
isInitialized,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ CLICKHOUSE_USER=default
|
||||
CLICKHOUSE_PASSWORD=default
|
||||
CLICKHOUSE_DATABASE=staging_ariadne
|
||||
|
||||
MAXMIND_ACCOUNT_ID=none
|
||||
MAXMIND_LICENSE_KEY=none
|
||||
|
||||
FLAME_ANVIL_URL=none
|
||||
@@ -141,4 +142,7 @@ COMPLIANCE_PAYOUT_THRESHOLD=disabled
|
||||
ANROK_API_KEY=none
|
||||
ANROK_API_URL=none
|
||||
|
||||
GOTENBERG_URL=http://labrinth-gotenberg:13000
|
||||
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
|
||||
|
||||
ARCHON_URL=none
|
||||
|
||||
@@ -115,6 +115,7 @@ CLICKHOUSE_USER=default
|
||||
CLICKHOUSE_PASSWORD=default
|
||||
CLICKHOUSE_DATABASE=staging_ariadne
|
||||
|
||||
MAXMIND_ACCOUNT_ID=none
|
||||
MAXMIND_LICENSE_KEY=none
|
||||
|
||||
FLAME_ANVIL_URL=none
|
||||
@@ -142,4 +143,7 @@ COMPLIANCE_PAYOUT_THRESHOLD=disabled
|
||||
ANROK_API_KEY=none
|
||||
ANROK_API_URL=none
|
||||
|
||||
GOTENBERG_URL=http://localhost:13000
|
||||
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
|
||||
|
||||
ARCHON_URL=none
|
||||
|
||||
@@ -113,6 +113,10 @@ migrations/20250725230041_reports-closed-status-index.sql
|
||||
migrations/20250727184120_user-newsletter-subscription-column.sql
|
||||
migrations/20250804221014_users-redeemals.sql
|
||||
migrations/20250805001654_product-prices-public.sql
|
||||
migrations/20250823233518_user-compliance.sql
|
||||
migrations/20250902133943_notification-extension.sql
|
||||
migrations/20250914190749_affiliate_codes.sql
|
||||
migrations/20250927120742_user_limits.sql
|
||||
|
||||
# Prettier reformats some of the PostgreSQL-specific COPY syntax here,
|
||||
# which is very likely to break things
|
||||
|
||||
+14
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n status = 'failed' AND due < NOW() - INTERVAL '30 days'\n ",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\",\n charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n \n INNER JOIN users_subscriptions us ON us.id = charges.subscription_id\n WHERE\n charges.charge_type = $1 AND\n (\n (charges.status = 'cancelled' AND charges.due < NOW()) OR\n (charges.status = 'expiring' AND charges.due < NOW()) OR\n (charges.status = 'failed' AND charges.last_attempt < NOW() - INTERVAL '2 days')\n )\n AND us.status = 'provisioned'\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -97,6 +97,16 @@
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "tax_transaction_version?",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "tax_platform_accounting_time?",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -123,8 +133,10 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e36e0ac1e2edb73533961a18e913f0b8e4f420a76e511571bb2eed9355771e54"
|
||||
"hash": "372c03a6daf0045f615faa9a6205558cd0ea1d9dba5948e8fa2496ed99de8fea"
|
||||
}
|
||||
Generated
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO users_subscriptions_credits\n (subscription_id, user_id, creditor_id, days, previous_due, next_due)\n SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::bigint[], $4::int[], $5::timestamptz[], $6::timestamptz[])\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array",
|
||||
"Int8Array",
|
||||
"Int8Array",
|
||||
"Int4Array",
|
||||
"TimestamptzArray",
|
||||
"TimestamptzArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3d05de766a0987028d465a3305938164e4d79734c17c07111f8923f2faa517bf"
|
||||
}
|
||||
+14
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE parent_charge_id = $1",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\",\n charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n WHERE user_id = $1 ORDER BY due DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -97,6 +97,16 @@
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "tax_transaction_version?",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "tax_platform_accounting_time?",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -123,8 +133,10 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "51c542076b4b3811eb12f051294f55827a27f51e65e668525b8b545f570c0bda"
|
||||
"hash": "4a0e5c7ebd4565b95fb99983484cec76952f1505a75eb1a006b3ad9b8aa91a51"
|
||||
}
|
||||
+14
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n INNER JOIN users_subscriptions us ON us.id = charges.subscription_id\n WHERE\n charges.charge_type = $1 AND\n (\n (charges.status = 'cancelled' AND charges.due < NOW()) OR\n (charges.status = 'expiring' AND charges.due < NOW()) OR\n (charges.status = 'failed' AND charges.last_attempt < NOW() - INTERVAL '2 days')\n )\n AND us.status = 'provisioned'\n ",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\",\n charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'open' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -97,6 +97,16 @@
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "tax_transaction_version?",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "tax_platform_accounting_time?",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -123,8 +133,10 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9f0c73fabe99d9891faaebdd3518b362437dcdcef9cd9a68b950fba61218bb4d"
|
||||
"hash": "4ed57832b7c02e1f4c683e256455c76e645cde49f95b0e5bfecd3d3d2330ed5c"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user