57 Commits

Author SHA1 Message Date
didirus 27abe2b42f Upgrade JDK version
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu-latest) (push) Successful in 40m21s
/ typos (pull_request) Failing after 1m5s
/ tombi (pull_request) Successful in 21s
/ shear (pull_request) Failing after 2m28s
AstralRinth App build / Build (x86_64-pc-windows-msvc, windows-latest) (push) Has been cancelled
2025-11-01 15:01:58 +03:00
didirus ece15a97a0 Update tauri configurations and CI build file
AstralRinth App build / Build (x86_64-pc-windows-msvc, windows-latest) (push) Has been cancelled
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu-latest) (push) Has been cancelled
2025-11-01 14:31:26 +03:00
didirus 97a9c24768 Merge tag 'v0.10.16' into beta 2025-11-01 14:14:52 +03:00
Prospector 3ac3122b31 Hide locked filter controls if they've been overridden (#4682) 2025-10-30 15:57:21 -07:00
Rayzeq 6b5f8a41e7 feat: split wrapper command on linux (#4427)
* feat: split wrapper command on linux

* feat: use code from #3900

* feat: also use shlex on Windows

* feat: add a version number to global settings

* feat(app): add settings v2, where wrapper command are split
2025-10-30 21:48:51 +00:00
Calum H. 8b39ba491a fix: keybind issues with gallery + moderation checklist (#4674) 2025-10-30 21:20:16 +00:00
Calum H. c74460fffa fix: firefox backup download issues (#4679)
* fix: firefox backup download issues

* fix: lint

* fix: hide download button when downloading & disable overflow menu options
2025-10-30 21:20:08 +00:00
Alejandro González 5000c4067b fix(app-lib): stricter override file path validation (#4681) 2025-10-30 21:19:23 +00:00
Calum H. af33950bbe fix: race condition for chart query param (#4677) 2025-10-30 19:19:44 +00:00
Calum H. 075331b26c fix: remove reply-to references in email templates (#4676) 2025-10-30 19:19:20 +00:00
Calum H. f31b74f7fd fix: show hostname on modrinth servers 503 (#4678)
* fix: show hostname on modrinth servers 503

* fix: lint
2025-10-30 19:18:24 +00:00
aecsocket bcc36362be Expose utilities for setting up the database (#4657)
* Expose utilities for setting up the database

* Expose migrator directly

* Make some test utils publicly accessible

* expose migrator

* more test fixture utils

* more test fixture utils

* more test fixture utils

* fix

* fix lint
2025-10-30 10:10:25 +00:00
Jerozgen 632b27dc21 Fix "Add friends" link (#4663) 2025-10-29 22:02:28 +00:00
Prospector cf6f3736eb Update ads.txt (#4670) 2025-10-29 21:59:16 +00:00
Prospector aaaef8f39e Fix double friends label, Closes #4655 (#4656)
* Fix double friends label, Closes #4655

* lint
2025-10-29 21:59:07 +00:00
Prospector 3f8dd1a79c changelog 2025-10-27 16:21:36 -07:00
Calum H. 363f47f269 feat: blog (#4653)
* feat: blog

* feat: creator withdraw overhaul blog

* fix: bullet points

* fix: title

* fix: blog

* feat: autoplay on scroll & fix encoding of videos

* fix: class on first video

* fix: authors + summary + title

* fix: title + summary

* fix: lint

* fix: rev page mp4

* update formatting + phrasing

* some more formatting changes

* unify hr colors

* update opening line

* update blog time

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-10-27 16:19:35 -07:00
Jerozgen a0f23a2bca Fix normalized skins uploading to Mojang (#4646)
* Fix normalized skins uploading to Mojang

* Run app-frontend > fix
2025-10-26 13:53:56 +00:00
Jerozgen 08e316a2b2 Move skin preview directional light (#4649) 2025-10-26 12:56:46 +00:00
Prospector 9aaf5fb87e update changelog 2025-10-24 20:55:58 -07:00
Prospector bcca66b12c Fix OLED colors (#4638)
* Make OLED theme proper dark again, shifting surface vars up one.

* Revert tertiary text color
2025-10-24 22:45:04 +00:00
Alejandro González ccb24ce8eb chore(vscode): use Rust analyzer rustfmt for formatting (#4637) 2025-10-24 21:37:02 +00:00
Prospector 5dd6c804d0 fix padding issues (#4604) 2025-10-24 18:58:20 +00:00
aecsocket ab886a5ea8 Fix CORS (#4610) 2025-10-24 18:27:44 +00:00
aecsocket 03b0eba695 Add utoipa Swagger UI support (#4602)
* Add utoipa Swagger UI support

* remove unused code

* remove unused code

* consistency with trailing slash
2025-10-24 14:44:50 +00:00
aecsocket 707ff2146b Update appropriate rows when removing a user (#4597)
* Update appropriate rows when removing a user

* Update sqlx cache

* Delete rows from payouts_values_notifications instead of make ghost user
2025-10-24 14:19:53 +00:00
Prospector 8d80433c2c Update 3-servers-bug.yml (#4607)
Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-10-22 18:15:41 -07:00
Prospector a547f7a9b0 Update issue templates (#4606)
* Update 1-app-bug.yml

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>

* update the rest of the templates

* Update issue template formatting further

* Disable blank issue + get rid of some contact links

* fix issue location id

* more updates

---------

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-10-22 18:13:49 -07:00
Calum H. f78fbe3215 fix: disable start button on backup restore/create (#4582)
* fix: CLAUDE.md

* fix: allowing start server on backup create/restore

---------

Signed-off-by: Calum H. <contact@cal.engineer>
2025-10-22 16:25:55 +00:00
François-Xavier Talbot f375913c62 Adjust some values in tax-related tasks (#4598)
* Adjust some values for tax processing

* chore: query cache, clippy, fmt
2025-10-21 15:55:54 +00:00
François-Xavier Talbot a4015d9df3 Fix v1 servers handling (#4596) 2025-10-21 06:40:10 +00:00
François-Xavier Talbot 977de0e18a Fix MaxMind (#4595)
* add maxmind to app data

* add back maxmind account id check
2025-10-21 00:24:47 +02:00
François-Xavier Talbot c379e4b173 admin/credit: don't credit unprovisioned subscriptions (#4594)
* Remove pointless sorting

* Filter subscriptions by labrinth's provisioned state
2025-10-20 20:31:20 +00:00
François-Xavier Talbot eeed4e572d Credit subscriptions (#4575)
* Implement subscription crediting

* chore: query cache, clippy, fmt

* Improve code, improve query for next open charge

* chore: query cache, clippy, fmt

* Move server ID copy button up

* Node + region crediting

* Make it less ugly

* chore: query cache, clippy, fmt

* Bugfixes

* Fix lint

* Adjust migration

* Adjust migration

* Remove billing change

* Move DEFAULT_CREDIT_EMAIL_MESSAGE to utils.ts

* Lint

* Merge

* bump clickhouse, disable validation

* tombi fmt

* Update cargo lock
2025-10-20 17:35:44 +00:00
François-Xavier Talbot 79502a19d6 bump clickhouse, disable validation (#4593)
* bump clickhouse, disable validation

* tombi fmt
2025-10-20 17:30:28 +00:00
François-Xavier Talbot 3dbfd69bdd Fix clickhouse (#4592) 2025-10-20 19:07:23 +02:00
Michael H. 19393a38bb fix(build): build on cargo.toml modifications 2025-10-20 18:59:02 +02:00
François-Xavier Talbot 859d7f57cf Downgrade CH dep (#4591) 2025-10-20 18:50:16 +02:00
aecsocket 24bec6baba Fix MaxMind (#4590) 2025-10-20 15:58:31 +00:00
Jerozgen 63d8f70e20 Fix friends texts (#4587) 2025-10-20 14:56:44 +00:00
François-Xavier Talbot 8a30b7978d Support ctoken_ in PATCH subscription (#4578) 2025-10-20 10:03:20 +00:00
Drew Chase 4a9f0b8a0e Include MAXIMIZED state flag in window state handling (#4566) 2025-10-20 01:00:11 +00:00
Calum H. 0e17427a58 fix: #4568 & i18n on user page (#4572)
* fix: #4568

* fix: lint
2025-10-20 00:23:06 +00:00
Prospector ad3b5aec69 update changelog 2025-10-19 17:32:22 -07:00
François-Xavier Talbot 4b17eb5d35 Gotenberg/PDF gen implementation (#4574)
* Gotenberg/PDF gen implementation

* Security, PDF type enum, propagate client

* chore: query cache, clippy, fmt

* clippy fixes + tombi

* Update env example, add GOTENBERG_CALLBACK_URL

* Remove test code

* Fix .env, docker-compose

* Update purpose of payment

* Add internal networking guards to gotenberg webhooks

* Fix error

* Fix lint
2025-10-19 23:56:26 +00:00
Prospector 6a70acef25 Updated ad placeholder graphics, update Modrinth App sidebar to mockup designs (#4584)
* Update ad placeholders to new green graphic

* Remove rounded corners from app ad frame

* Improve web ad placeholder styling

* Revamp app sidebar to match mockups more closely, greatly improve friends UX, fix up context menus and typify shit

* only show overflow on hover

* lint

* intl:extract

* clean up the inline code in FriendsSection
2025-10-19 23:26:17 +00:00
Prospector e58456eed4 Fix using the wrong icon for open folder on instance ctx menu (#4586) 2025-10-19 23:18:27 +00:00
Calum H. 12940fc207 fix: default subscription interval in servers upgrade modal wrapper (#4585) 2025-10-19 23:16:13 +00:00
François-Xavier Talbot 7796273529 Clearer error on TIN mismatch (#4579)
* Clearer error on TIN mismatch

* Remove ctoken code (how did that end up there)

* polish: frontend for TIN/SSN mismatch

* fix: lint

* polish: only banner + change text

* fix: logic

* fix: lint

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2025-10-19 21:03:21 +00:00
didirus 7cc9d8183d fix: update.js top level awaiting
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu-latest) (push) Successful in 36m27s
AstralRinth App build / Build (x86_64-pc-windows-msvc, windows-latest) (push) Has been cancelled
Crowdin (pull) / Pull translations from Crowdin (push) Has been skipped
2025-10-19 21:48:45 +03:00
Michael H. 752f68124c fix: sync debian version with rust image 2025-10-19 18:11:41 +02:00
Calum H. 699a049c69 fix: medal upgrade for new payment methods (#4581) 2025-10-19 12:31:15 +00:00
aecsocket fa7d1d7942 Use new MaxMind env vars on Labrinth (#4573)
* Bring in modrinth-maxmind

* integrate modrinth-maxmind into labrinth

* Fix CI
2025-10-18 18:38:19 +00:00
Calum H. d1ffed564d fix: #4567 (#4571) 2025-10-17 17:56:25 +00:00
Alejandro González e719ae2f42 fix(daedalus-client): backport new Mojang MC version library patches from PrismLauncher (#4493)
While researching and fixing other issue, it caught my attention that we
are embedding a library patches JSON file from the PrismLauncher meta
repository. However, since we copied that file, a new revision of it was
published with patches that improve compatibility with Apple Silicon
macOS platforms.

These changes update such a file and, perhaps most importantly, add a
comment explaining the provenance and licensing of such a file.
2025-10-17 16:43:04 +00:00
François-Xavier Talbot 5db5bf4c4c Changes to handling of refunds in Anrok (#4556)
* Use negations, track transaction version/accounting time, use original charge accounting time in refunds

* query cache

* chore: query cache, clippy, fmt

* Fix tax drift calculation

* Fix migration

* Increase update_tax_transactions rate
2025-10-17 15:57:36 +00:00
Josiah Glosson b23d3e674f Update Rust & Java dependencies (#4540)
* Update Java dependencies

* Baselint lint fixes

* Update Rust version

* Update actix-files 0.6.6 -> 0.6.8

* Update actix-http 3.11.0 -> 3.11.2

* Update actix-rt 2.10.0 -> 2.11.0

* Update async_zip 0.0.17 -> 0.0.18

* Update async-compression 0.4.27 -> 0.4.32

* Update async-trait 0.1.88 -> 0.1.89

* Update async-tungstenite 0.30.0 -> 0.31.0

* Update const_format 0.2.34 -> 0.2.35

* Update bitflags 2.9.1 -> 2.9.4

* Update bytemuck 1.23.1 -> 1.24.0

* Update typed-path 0.11.0 -> 0.12.0

* Update chrono 0.4.41 -> 0.4.42

* Update cidre 0.11.2 -> 0.11.3

* Update clap 4.5.43 -> 4.5.48

* Update data-url 0.3.1 -> 0.3.2

* Update discord-rich-presence 0.2.5 -> 1.0.0

* Update enumset 1.1.7 -> 1.1.10

* Update flate2 1.1.2 -> 1.1.4

* Update hyper 1.6.0 -> 1.7.0

* Update hyper-util 0.1.16 -> 0.1.17

* Update iana-time-zone 0.1.63 -> 0.1.64

* Update image 0.25.6 -> 0.25.8

* Update indexmap 2.10.0 -> 2.11.4

* Update json-patch 4.0.0 -> 4.1.0

* Update meilisearch-sdk 0.29.1 -> 0.30.0

* Update clickhouse 0.13.3 -> 0.14.0

* Fix some prettier things

* Update lettre 0.11.18 -> 0.11.19

* Update phf 0.12.1 -> 0.13.1

* Update png 0.17.16 -> 0.18.0

* Update quick-xml 0.38.1 -> 0.38.3

* Update redis 0.32.4 -> 0.32.7

* Update regex 1.11.1 -> 1.11.3

* Update reqwest 0.12.22 -> 0.12.23

* Update rust_decimal 1.37.2 -> 1.38.0

* Update rust-s3 0.35.1 -> 0.37.0

* Update serde 1.0.219 -> 1.0.228

* Update serde_bytes 0.11.17 -> 0.11.19

* Update serde_json 1.0.142 -> 1.0.145

* Update serde_with 3.14.0 -> 3.15.0

* Update sentry 0.42.0 -> 0.45.0 and sentry-actix 0.42.0 -> 0.45.0

* Update spdx 0.10.9 -> 0.12.0

* Update sysinfo 0.36.1 -> 0.37.2

* Update tauri 2.7.0 -> 2.8.5

* Update tauri-build 2.3.1 -> 2.4.1

* Update tauri-plugin-deep-link 2.4.1 -> 2.4.3

* Update tauri-plugin-dialog 2.3.2 -> 2.4.0

* Update tauri-plugin-http 2.5.1 -> 2.5.2

* Update tauri-plugin-opener 2.4.0 -> 2.5.0

* Update tauri-plugin-os 2.3.0 -> 2.3.1

* Update tauri-plugin-single-instance 2.3.2 -> 2.3.4

* Update tempfile 3.20.0 -> 3.23.0

* Update thiserror 2.0.12 -> 2.0.17

* Update tracing-subscriber 0.3.19 -> 0.3.20

* Update url 2.5.4 -> 2.5.7

* Update uuid 1.17.0 -> 1.18.1

* Update webp 0.3.0 -> 0.3.1

* Update whoami 1.6.0 -> 1.6.1

* Note that windows and windows-core can't be updated yet

* Update zbus 5.9.0 -> 5.11.0

* Update zip 4.3.0 -> 6.0.0

* Fix build

* Enforce rustls crypto provider

* Refresh Cargo.lock

* Update transitive dependencies

* Bump Gradle usage to Java 17

* Use ubuntu-latest consistently across workflows

* Fix lint

* Fix lint in Rust

* Update native-dialog 0.9.0 -> 0.9.2

* Update regex 1.11.3 -> 1.12.2

* Update reqwest 0.12.23 -> 0.12.24

* Update rust_decimal 1.38.0 -> 1.39.0

* Remaining lock-only updates

* chore: move TLS impl of some other dependencies to aws-lc-rs

The AWS bloatware "virus" expands by sheer force of widespread adoption
by the ecosystem... 🫣

* chore(fmt): run Tombi

---------

Co-authored-by: Alejandro González <me@alegon.dev>
2025-10-15 20:45:47 +00:00
208 changed files with 6332 additions and 2171 deletions
+63
View File
@@ -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
+4 -10
View File
@@ -1,14 +1,8 @@
blank_issues_enabled: true blank_issues_enabled: false
contact_links: contact_links:
- name: 🫶 Support Portal - name: 🫶 Support portal
about: Get support using through our portal. about: Get support using through our support website.
url: https://support.modrinth.com url: https://support.modrinth.com
- name: 💬 Chat - name: 💬 Chat on Discord
about: Join our Discord server to chat about Modrinth. about: Join our Discord server to chat about Modrinth.
url: https://discord.modrinth.com 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
+2 -2
View File
@@ -100,7 +100,7 @@ jobs:
libayatana-appindicator3-dev \ libayatana-appindicator3-dev \
librsvg2-dev \ librsvg2-dev \
xdg-utils \ xdg-utils \
openjdk-11-jdk openjdk-17-jdk
- name: ⚙️ Set application environment - name: ⚙️ Set application environment
shell: bash shell: bash
@@ -143,7 +143,7 @@ jobs:
if: matrix.platform == 'windows-latest' if: matrix.platform == 'windows-latest'
shell: pwsh shell: pwsh
run: | run: |
$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' pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis'
env: env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
+1 -1
View File
@@ -11,7 +11,7 @@ concurrency:
jobs: jobs:
pull_translations: pull_translations:
name: 'Pull translations from Crowdin' name: 'Pull translations from Crowdin'
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
concurrency: concurrency:
group: i18n-pull:${{ github.ref }} group: i18n-pull:${{ github.ref }}
+1 -1
View File
@@ -18,7 +18,7 @@ concurrency:
jobs: jobs:
push_translations: push_translations:
name: Push sources to Crowdin name: Push sources to Crowdin
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
concurrency: concurrency:
group: i18n-push:${{ github.ref }} group: i18n-push:${{ github.ref }}
+19 -1
View File
@@ -11,5 +11,23 @@
"source.fixAll.eslint": "explicit", "source.fixAll.eslint": "explicit",
"source.organizeImports": "always" "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"
}
} }
+31
View File
@@ -1,5 +1,35 @@
# Architecture # 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
Labrinth is the backend API service for Modrinth. 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. 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: When the user refers to "performing pre-PR checks", do the following:
- Run clippy as described above - Run clippy as described above
- DO NOT run tests unless explicitly requested (they take a long time) - DO NOT run tests unless explicitly requested (they take a long time)
- Prepare the sqlx cache - Prepare the sqlx cache
Generated
+738 -471
View File
File diff suppressed because it is too large Load Diff
+81 -68
View File
@@ -8,92 +8,97 @@ members = [
"packages/app-lib", "packages/app-lib",
"packages/ariadne", "packages/ariadne",
"packages/daedalus", "packages/daedalus",
"packages/modrinth-maxmind",
"packages/modrinth-util",
"packages/path-util", "packages/path-util",
] ]
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
rust-version = "1.90.0"
repository = "https://github.com/modrinth/code"
[workspace.dependencies] [workspace.dependencies]
actix-cors = "0.7.1" actix-cors = "0.7.1"
actix-files = "0.6.6" actix-files = "0.6.8"
actix-http = "3.11.0" actix-http = "3.11.2"
actix-multipart = "0.7.2" actix-multipart = "0.7.2"
actix-rt = "2.10.0" actix-rt = "2.11.0"
actix-web = "4.11.0" actix-web = "4.11.0"
actix-web-prom = "0.10.0" actix-web-prom = "0.10.0"
actix-ws = "0.3.0" actix-ws = "0.3.0"
argon2 = { version = "0.5.3", features = ["std"] } argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" } ariadne = { path = "packages/ariadne" }
async-compression = { version = "0.4.27", default-features = false } async-compression = { version = "0.4.32", default-features = false }
async-recursion = "1.1.1" async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [ async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls", "runtime-tokio-hyper-rustls",
] } ] }
async-trait = "0.1.88" async-trait = "0.1.89"
async-tungstenite = { version = "0.30.0", default-features = false, features = [ async-tungstenite = { version = "0.31.0", default-features = false, features = [
"futures-03-sink", "futures-03-sink"
] } ] }
async-walkdir = "2.1.0" async-walkdir = "2.1.0"
async_zip = "0.0.17" async_zip = "0.0.18"
base64 = "0.22.1" base64 = "0.22.1"
bitflags = "2.9.1" bitflags = "2.9.4"
bytemuck = "1.23.1" bytemuck = "1.24.0"
bytes = "1.10.1" bytes = "1.10.1"
censor = "0.3.0" censor = "0.3.0"
chardetng = "0.1.17" chardetng = "0.1.17"
chrono = "0.4.41" chrono = "0.4.42"
cidre = { version = "0.11.2", default-features = false, features = [ cidre = { version = "0.11.3", default-features = false, features = [
"macos_15_0", "macos_15_0"
] } ] }
clap = "4.5.43" clap = "4.5.48"
clickhouse = "0.13.3" clickhouse = "0.14.0"
color-eyre = "0.6.5" color-eyre = "0.6.5"
color-thief = "0.2.2" color-thief = "0.2.2"
console-subscriber = "0.4.1" console-subscriber = "0.4.1"
const_format = "0.2.34" const_format = "0.2.34"
daedalus = { path = "packages/daedalus" } daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0" dashmap = "6.1.0"
data-url = "0.3.1" data-url = "0.3.2"
deadpool-redis = "0.22.0" deadpool-redis = "0.22.0"
derive_more = "2.0.1" derive_more = "2.0.1"
directories = "6.0.0"
dirs = "6.0.0" dirs = "6.0.0"
discord-rich-presence = "0.2.5" discord-rich-presence = "1.0.0"
dotenv-build = "0.1.1" dotenv-build = "0.1.1"
dotenvy = "0.15.7" dotenvy = "0.15.7"
dunce = "1.0.5" dunce = "1.0.5"
either = "1.15.0" either = "1.15.0"
encoding_rs = "0.8.35" encoding_rs = "0.8.35"
enumset = "1.1.7" enumset = "1.1.10"
eyre = "0.6.12" eyre = "0.6.12"
flate2 = "1.1.2" flate2 = "1.1.4"
fs4 = { version = "0.13.1", default-features = false } 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" futures-util = "0.3.31"
heck = "0.5.0" heck = "0.5.0"
hex = "0.4.3" hex = "0.4.3"
hickory-resolver = "0.25.2" hickory-resolver = "0.25.2"
hmac = "0.12.1" hmac = "0.12.1"
hyper = "1.6.0" hyper = "1.7.0"
hyper-rustls = { version = "0.27.7", default-features = false, features = [ hyper-rustls = { version = "0.27.7", default-features = false, features = [
"aws-lc-rs",
"http1", "http1",
"native-tokio", "native-tokio",
"ring",
"tls12", "tls12",
] } ] }
hyper-util = "0.1.16" hyper-util = "0.1.17"
iana-time-zone = "0.1.63" iana-time-zone = "0.1.64"
image = { version = "0.25.6", default-features = false, features = ["rayon"] } image = { version = "0.25.8", default-features = false, features = ["rayon"] }
indexmap = "2.10.0" indexmap = "2.11.4"
indicatif = "0.18.0" indicatif = "0.18.0"
itertools = "0.14.0" itertools = "0.14.0"
jemalloc_pprof = "0.8.1" jemalloc_pprof = "0.8.1"
json-patch = { version = "4.0.0", default-features = false } json-patch = { version = "4.1.0", default-features = false }
lettre = { version = "0.11.18", default-features = false, features = [ lettre = { version = "0.11.19", default-features = false, features = [
"aws-lc-rs",
"builder", "builder",
"hostname", "hostname",
"pool", "pool",
"ring",
"rustls", "rustls",
"rustls-native-certs", "rustls-native-certs",
"smtp-transport", "smtp-transport",
@@ -101,37 +106,40 @@ lettre = { version = "0.11.18", default-features = false, features = [
"tokio1-rustls", "tokio1-rustls",
] } ] }
maxminddb = "0.26.0" 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" murmur2 = "0.1.0"
native-dialog = "0.9.0" native-dialog = "0.9.2"
notify = { version = "8.2.0", default-features = false } notify = { version = "8.2.0", default-features = false }
notify-debouncer-mini = { version = "0.7.0", default-features = false } notify-debouncer-mini = { version = "0.7.0", default-features = false }
p256 = "0.13.2" p256 = "0.13.2"
paste = "1.0.15" paste = "1.0.15"
path-util = { path = "packages/path-util" } path-util = { path = "packages/path-util" }
phf = { version = "0.12.1", features = ["macros"] } phf = { version = "0.13.1", features = ["macros"] }
png = "0.17.16" png = "0.18.0"
prometheus = "0.14.0" prometheus = "0.14.0"
quartz_nbt = "0.2.9" quartz_nbt = "0.2.9"
quick-xml = "0.38.1" quick-xml = "0.38.3"
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9 rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9 rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "0.32.4" redis = "0.32.7"
regex = "1.11.1" regex = "1.12.2"
reqwest = { version = "0.12.22", default-features = false } reqwest = { version = "0.12.24", default-features = false }
rgb = "0.8.52" rgb = "0.8.52"
rust_decimal = { version = "1.37.2", features = [ rust_decimal = { version = "1.39.0", features = [
"serde-with-float", "serde-with-float",
"serde-with-str", "serde-with-str"
] } ] }
rust_iso3166 = "0.1.14" rust_iso3166 = "0.1.14"
rust-s3 = { version = "0.35.1", default-features = false, features = [ rust-s3 = { version = "0.37.0", default-features = false, features = [
"fail-on-err", "fail-on-err",
"tags", "tags",
"tokio-rustls-tls", "tokio-rustls-tls",
] } ] }
rustls = "0.23.32"
rusty-money = "0.4.1" rusty-money = "0.4.1"
sentry = { version = "0.42.0", default-features = false, features = [ sentry = { version = "0.45.0", default-features = false, features = [
"backtrace", "backtrace",
"contexts", "contexts",
"debug-images", "debug-images",
@@ -139,37 +147,38 @@ sentry = { version = "0.42.0", default-features = false, features = [
"reqwest", "reqwest",
"rustls", "rustls",
] } ] }
sentry-actix = "0.42.0" sentry-actix = "0.45.0"
serde = "1.0.219" serde = "1.0.228"
serde_bytes = "0.11.17" serde_bytes = "0.11.19"
serde_cbor = "0.11.2" serde_cbor = "0.11.2"
serde_ini = "0.2.0" serde_ini = "0.2.0"
serde_json = "1.0.142" serde_json = "1.0.145"
serde_with = "3.14.0" serde_with = "3.15.0"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6" sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] } sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9" sha2 = "0.10.9"
spdx = "0.10.9" shlex = "1.3.0"
spdx = "0.12.0"
sqlx = { version = "0.8.6", default-features = false } 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" tar = "0.4.44"
tauri = "2.7.0" tauri = "2.8.5"
tauri-build = "2.3.1" tauri-build = "2.4.1"
tauri-plugin-deep-link = "2.4.1" tauri-plugin-deep-link = "2.4.3"
tauri-plugin-dialog = "2.3.2" tauri-plugin-dialog = "2.4.0"
tauri-plugin-http = "2.5.1" tauri-plugin-http = "2.5.2"
tauri-plugin-opener = "2.4.0" tauri-plugin-opener = "2.5.0"
tauri-plugin-os = "2.3.0" tauri-plugin-os = "2.3.1"
tauri-plugin-single-instance = "2.3.2" tauri-plugin-single-instance = "2.3.4"
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [ tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
"rustls-tls", "rustls-tls",
"zip", "zip",
] } ] }
tauri-plugin-window-state = "2.4.0" tauri-plugin-window-state = "2.4.0"
tempfile = "3.20.0" tempfile = "3.23.0"
theseus = { path = "packages/app-lib" } theseus = { path = "packages/app-lib" }
thiserror = "2.0.12" thiserror = "2.0.17"
tikv-jemalloc-ctl = "0.6.0" tikv-jemalloc-ctl = "0.6.0"
tikv-jemallocator = "0.6.0" tikv-jemallocator = "0.6.0"
tokio = "1.47.1" tokio = "1.47.1"
@@ -180,22 +189,25 @@ tracing = "0.1.41"
tracing-actix-web = { version = "0.7.19", default-features = false } tracing-actix-web = { version = "0.7.19", default-features = false }
tracing-ecs = "0.5.0" tracing-ecs = "0.5.0"
tracing-error = "0.2.1" tracing-error = "0.2.1"
tracing-subscriber = "0.3.19" tracing-subscriber = "0.3.20"
typed-path = "0.11.0" typed-path = "0.12.0"
url = "2.5.4" url = "2.5.7"
urlencoding = "2.1.3" 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" validator = "0.20.0"
webp = { version = "0.3.0", default-features = false } webp = { version = "0.3.1", default-features = false }
webview2-com = "0.38.0" # Should be updated in lockstep with wry webview2-com = "0.38.0" # Should be updated in lockstep with wry
whoami = "1.6.0" whoami = "1.6.1"
windows = "0.61.3" windows = "=0.61.3" # Locked on 0.61 until we can update windows-core to 0.62
windows-core = "0.61.2" windows-core = "=0.61.2" # Locked on 0.61 until webview2-com updates to 0.62
winreg = "0.55.0" winreg = "0.55.0"
woothee = "0.13.0" woothee = "0.13.0"
yaserde = "0.12.0" yaserde = "0.12.0"
zbus = "5.9.0" zbus = "5.11.0"
zip = { version = "4.3.0", default-features = false, features = [ zip = { version = "6.0.0", default-features = false, features = [
"bzip2", "bzip2",
"deflate", "deflate",
"deflate64", "deflate64",
@@ -234,6 +246,7 @@ redundant_clone = "warn"
redundant_feature_names = "warn" redundant_feature_names = "warn"
redundant_type_annotations = "warn" redundant_type_annotations = "warn"
todo = "warn" todo = "warn"
uninlined_format_args = "warn"
unnested_or_patterns = "warn" unnested_or_patterns = "warn"
wildcard_dependencies = "warn" wildcard_dependencies = "warn"
+50 -32
View File
@@ -4,6 +4,7 @@ import {
ChangeSkinIcon, ChangeSkinIcon,
CompassIcon, CompassIcon,
DownloadIcon, DownloadIcon,
ExternalIcon,
HomeIcon, HomeIcon,
LeftArrowIcon, LeftArrowIcon,
LibraryIcon, LibraryIcon,
@@ -18,6 +19,7 @@ import {
RestoreIcon, RestoreIcon,
RightArrowIcon, RightArrowIcon,
SettingsIcon, SettingsIcon,
UserIcon,
WorldIcon, WorldIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
@@ -65,7 +67,7 @@ import { debugAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics
import { get_user } from '@/helpers/cache.js' import { get_user } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.js' import { command_listener, warning_listener } from '@/helpers/events.js'
import { useFetch } from '@/helpers/fetch.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 { list } from '@/helpers/profile.js'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state' import { get_opening_command, initialize_state } from '@/helpers/state'
@@ -668,7 +670,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) // [AR Note] If delete this
v-tooltip.right="formatMessage(commonMessages.settingsLabel)" v-tooltip.right="formatMessage(commonMessages.settingsLabel)"
:to="() => $refs.settingsModal.show()"> :to="() => $refs.settingsModal.show()">
<SettingsIcon /> <SettingsIcon />
</NavButton> </NavButton>
</template> </template>
<template v-else> <template v-else>
<NavButton <NavButton
@@ -677,29 +679,39 @@ provideAppUpdateDownloadProgress(appUpdateDownload) // [AR Note] If delete this
<SettingsIcon /> <SettingsIcon />
</NavButton> </NavButton>
</template> </template>
<ButtonStyled v-if="credentials" type="transparent" circular> <OverflowMenu
<OverflowMenu v-if="credentials"
:options="[ 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"
id: 'sign-out', :options="[
action: () => logOut(), {
color: 'danger', id: 'view-profile',
}, action: () => openUrl('https://modrinth.com/user/' + credentials.user.username),
]" },
direction="left" {
> id: 'sign-out',
<Avatar action: () => logOut(),
:src="credentials.user.avatar_url" color: 'danger',
:alt="credentials.user.username" },
size="32px" ]"
circle placement="right-end"
/> >
<template #sign-out> <LogOutIcon /> Sign out </template> <Avatar :src="credentials.user.avatar_url" alt="" size="32px" circle />
</OverflowMenu> <template #view-profile>
</ButtonStyled> <UserIcon />
<NavButton v-else v-tooltip.right="'Sign in'" :to="() => signIn()"> <span class="inline-flex items-center gap-1">
<LogInIcon /> Signed in as
<template #label>Sign in</template> <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> </NavButton>
</div> </div>
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex"> <div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
@@ -837,20 +849,26 @@ provideAppUpdateDownloadProgress(appUpdateDownload) // [AR Note] If delete this
> >
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div> <div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }"> <div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"> <div
<h3 class="text-lg m-0">Playing as</h3> 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> <suspense>
<AccountsCard ref="accounts" mode="small" /> <AccountsCard ref="accounts" mode="small" />
</suspense> </suspense>
</div> </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> <suspense>
<FriendsList :credentials="credentials" :sign-in="() => signIn()" /> <FriendsList
:credentials="credentials"
:sign-in="() => signIn()"
:refresh-credentials="fetchCredentials"
/>
</suspense> </suspense>
</div> </div>
<div v-if="news && news.length > 0" class="pt-4 flex flex-col items-center"> <div v-if="news && news.length > 0" class="p-4 pr-1 flex flex-col items-center">
<h3 class="px-4 text-lg m-0 text-left w-full">News</h3> <h3 class="text-base mb-4 text-primary font-medium 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 class="space-y-4 flex flex-col items-center w-full">
<NewsArticleCard <NewsArticleCard
v-for="(item, index) in news" v-for="(item, index) in news"
:key="`news-${index}`" :key="`news-${index}`"
@@ -24,7 +24,7 @@
</template> </template>
<script setup> <script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue' import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
const emit = defineEmits(['menu-closed', 'option-clicked']) const emit = defineEmits(['menu-closed', 'option-clicked'])
@@ -40,22 +40,27 @@ defineExpose({
item.value = passedItem item.value = passedItem
options.value = passedOptions options.value = passedOptions
const menuWidth = contextMenu.value.clientWidth // show to get dimensions
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'
}
shown.value = true 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'
}
})
}, },
}) })
@@ -1,41 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
MailIcon,
MoreVerticalIcon,
SettingsIcon,
TrashIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import { import {
Avatar, Avatar,
ButtonStyled, ButtonStyled,
commonMessages,
injectNotificationManager, injectNotificationManager,
OverflowMenu,
useRelativeTime, useRelativeTime,
} from '@modrinth/ui' } from '@modrinth/ui'
import type { Dayjs } from 'dayjs' import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs' import { IntlFormatted } from '@vintl/vintl/components'
import { computed, onUnmounted, ref, watch } from 'vue' 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 ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_user_many } from '@/helpers/cache'
import { friend_listener } from '@/helpers/events' 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 { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime() const formatRelativeTime = useRelativeTime()
const props = defineProps<{ const props = defineProps<{
credentials: unknown | null credentials: ModrinthCredentials | null
signIn: () => void signIn: () => void
}>() }>()
const userCredentials = computed(() => props.credentials) const userCredentials = computed(() => props.credentials)
const search = ref('') const search = ref('')
const manageFriendsModal = ref()
const friendInvitesModal = ref() const friendInvitesModal = ref()
const username = ref('') const username = ref('')
@@ -47,61 +47,64 @@ async function addFriendFromModal() {
await loadFriends() await loadFriends()
} }
const friendOptions = ref() async function addFriend(friend: FriendWithUserData) {
async function handleFriendOptions(args) { const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
switch (args.option) { if (id) {
case 'remove-friend': await add_friend(id).catch(handleError)
await removeFriend(args.item) await loadFriends()
break
} }
} }
async function addFriend(friend: Friend) { async function removeFriend(friend: FriendWithUserData) {
await add_friend( const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id, if (id) {
).catch(handleError) await remove_friend(id).catch(handleError)
await loadFriends() await loadFriends()
}
} }
async function removeFriend(friend: Friend) { const userFriends = ref<FriendWithUserData[]>([])
await remove_friend( const sortedFriends = computed<FriendWithUserData[]>(() =>
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id, userFriends.value.slice().sort((a, b) => {
).catch(handleError) if (a.last_updated === null && b.last_updated === null) {
await loadFriends() 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 = { const activeFriends = computed<FriendWithUserData[]>(() =>
id: string filteredFriends.value.filter((x) => !!x.status && x.online && x.accepted),
friend_id: string | null )
status: string | null const onlineFriends = computed<FriendWithUserData[]>(() =>
last_updated: Dayjs | null filteredFriends.value.filter((x) => x.online && !x.status && x.accepted),
created: Dayjs )
username: string const offlineFriends = computed<FriendWithUserData[]>(() =>
accepted: boolean filteredFriends.value.filter((x) => !x.online && x.accepted),
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 pendingFriends = computed(() => 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) const loading = ref(true)
@@ -110,34 +113,7 @@ async function loadFriends(timeout = false) {
try { try {
const friendsList = await friends() const friendsList = await friends()
userFriends.value = await transformFriends(friendsList, userCredentials.value)
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,
}
})
}
loading.value = false loading.value = false
} catch (e) { } catch (e) {
console.error('Error loading friends', e) console.error('Error loading friends', e)
@@ -152,6 +128,7 @@ watch(
() => { () => {
if (userCredentials.value === undefined) { if (userCredentials.value === undefined) {
userFriends.value = [] userFriends.value = []
loading.value = false
} else if (userCredentials.value === null) { } else if (userCredentials.value === null) {
userFriends.value = [] userFriends.value = []
loading.value = false loading.value = false
@@ -166,49 +143,87 @@ const unlisten = await friend_listener(() => loadFriends())
onUnmounted(() => { onUnmounted(() => {
unlisten() 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> </script>
<template> <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"> <ModalWrapper ref="friendInvitesModal" header="View friend requests">
<p v-if="pendingFriends.length === 0">You have no pending friend requests :C</p> <p v-if="incomingRequests.length === 0">You have no pending friend requests :C</p>
<div v-else class="flex flex-col gap-4"> <div v-else class="flex flex-col gap-4 min-w-[40rem]">
<div v-for="friend in pendingFriends" :key="friend.username" class="flex gap-2"> <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 /> <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> <div>
<p class="m-0"> <p class="m-0">
<template v-if="friend.id === userCredentials.user_id"> <template v-if="friend.id === userCredentials?.user_id">
<span class="font-bold">{{ friend.username }}</span> sent you a friend request <span class="text-contrast">{{ friend.username }}</span> sent you a friend request
</template> </template>
<template v-else> <template v-else>
You sent <span class="font-bold">{{ friend.username }}</span> a friend request You sent <span class="font-bold">{{ friend.username }}</span> a friend request
@@ -219,7 +234,7 @@ onUnmounted(() => {
</p> </p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<template v-if="friend.id === userCredentials.user_id"> <template v-if="friend.id === userCredentials?.user_id">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button @click="addFriend(friend)"> <button @click="addFriend(friend)">
<UserPlusIcon /> <UserPlusIcon />
@@ -246,78 +261,89 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
<ModalWrapper ref="addFriendModal" header="Add a friend"> <ModalWrapper ref="addFriendModal" :header="formatMessage(messages.addingAFriend)">
<div class="mb-4"> <div class="min-w-[30rem]">
<h2 class="m-0 text-lg font-extrabold text-contrast">Username</h2> <h2 class="m-0 text-base font-medium text-primary">
<p class="m-0 mt-1 leading-tight">You can add friends with their Modrinth username.</p> {{ formatMessage(messages.usernameTitle) }}
<input </h2>
v-model="username" <p class="m-0 mt-1 text-sm text-secondary leading-tight">
class="mt-2 w-full" {{ formatMessage(messages.usernameDescription) }}
type="text" </p>
placeholder="Enter username..." <div class="flex items-center gap-2 mt-4">
@keyup.enter="addFriendFromModal" <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> </div>
<ButtonStyled color="brand"> </ModalWrapper>
<button :disabled="username.length === 0" @click="addFriendFromModal"> <div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 ml-2 mr-1">
<UserPlusIcon /> <template v-if="sortedFriends.length > 0">
Add friend <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> </button>
</ButtonStyled> </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>
<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"> <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="min-w-9 min-h-9 bg-button-bg rounded-full"></div>
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div> <div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div>
@@ -325,50 +351,77 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</template> </template>
<template v-else-if="acceptedFriends.length === 0"> <template v-else-if="sortedFriends.length === 0">
<div class="text-sm"> <div class="text-sm ml-4 mr-1">
<div v-if="!userCredentials"> <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>
<div v-else> <div v-else>
<span class="text-link cursor-pointer" @click="addFriendModal.show()">Add friends</span> <IntlFormatted :message-id="messages.addFriendsToShare">
to share what you're playing! <template #link="{ children }">
<span class="font-semibold text-brand cursor-pointer" @click="addFriendModal.show">
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</div> </div>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions"> <FriendsSection
<template #remove-friend> <TrashIcon /> Remove friend </template> v-if="activeFriends.length > 0"
</ContextMenu> :is-searching="!!search"
<div open-by-default
v-for="friend in acceptedFriends.slice(0, 5)" :friends="activeFriends"
:key="friend.username" :heading="formatMessage(messages.active)"
class="flex gap-2 items-center" :remove-friend="removeFriend"
:class="{ grayscale: !friend.online }" />
@contextmenu.prevent.stop=" <FriendsSection
(event) => v-if="onlineFriends.length > 0"
friendOptions.showMenu(event, friend, [ :is-searching="!!search"
{ open-by-default
name: 'remove-friend', :friends="onlineFriends"
color: 'danger', :heading="formatMessage(messages.online)"
}, :remove-friend="removeFriend"
]) />
" <FriendsSection
> v-if="offlineFriends.length > 0"
<div class="relative"> :is-searching="!!search"
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle /> :open-by-default="activeFriends.length + onlineFriends.length < 3"
<span :friends="offlineFriends"
v-if="friend.online" :heading="formatMessage(messages.offline)"
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full" :remove-friend="removeFriend"
/> />
</div> <FriendsSection
<div class="flex flex-col"> v-if="pendingFriends.length > 0"
<span class="text-md m-0 font-medium" :class="{ 'text-secondary': !friend.online }"> :is-searching="!!search"
{{ friend.username }} open-by-default
</span> :friends="pendingFriends"
<span v-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span> :heading="formatMessage(messages.pending)"
</div> :remove-friend="removeFriend"
</div> />
<p v-if="filteredFriends.length === 0 && search" class="text-sm text-secondary my-1 mx-4">
{{ formatMessage(messages.noFriendsMatch, { query: search }) }}
</p>
</template> </template>
</div> </div>
</template> </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, remove_custom_skin,
type Skin, type Skin,
type SkinModel, type SkinModel,
type SkinTextureUrl,
unequip_skin, unequip_skin,
} from '@/helpers/skins.ts' } from '@/helpers/skins.ts'
@@ -142,7 +143,7 @@ const currentSkin = ref<Skin | null>(null)
const shouldRestoreModal = ref(false) const shouldRestoreModal = ref(false)
const isSaving = ref(false) const isSaving = ref(false)
const uploadedTextureUrl = ref<string | null>(null) const uploadedTextureUrl = ref<SkinTextureUrl | null>(null)
const previewSkin = ref<string>('') const previewSkin = ref<string>('')
const variant = ref<SkinModel>('CLASSIC') const variant = ref<SkinModel>('CLASSIC')
@@ -188,7 +189,7 @@ function getSortedCapeExcluding(excludeId: string): Cape | undefined {
async function loadPreviewSkin() { async function loadPreviewSkin() {
if (uploadedTextureUrl.value) { if (uploadedTextureUrl.value) {
previewSkin.value = uploadedTextureUrl.value previewSkin.value = uploadedTextureUrl.value.normalized
} else if (currentSkin.value) { } else if (currentSkin.value) {
try { try {
previewSkin.value = await get_normalized_skin_texture(currentSkin.value) previewSkin.value = await get_normalized_skin_texture(currentSkin.value)
@@ -253,11 +254,11 @@ async function show(e: MouseEvent, skin?: Skin) {
modal.value?.show(e) modal.value?.show(e)
} }
async function showNew(e: MouseEvent, skinTextureUrl: string) { async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
mode.value = 'new' mode.value = 'new'
currentSkin.value = null currentSkin.value = null
uploadedTextureUrl.value = skinTextureUrl uploadedTextureUrl.value = skinTextureUrl
variant.value = await determineModelType(skinTextureUrl) variant.value = await determineModelType(skinTextureUrl.original)
selectedCape.value = undefined selectedCape.value = undefined
visibleCapeList.value = [] visibleCapeList.value = []
initVisibleCapeList() initVisibleCapeList()
@@ -267,7 +268,7 @@ async function showNew(e: MouseEvent, skinTextureUrl: string) {
modal.value?.show(e) modal.value?.show(e)
} }
async function restoreWithNewTexture(skinTextureUrl: string) { async function restoreWithNewTexture(skinTextureUrl: SkinTextureUrl) {
uploadedTextureUrl.value = skinTextureUrl uploadedTextureUrl.value = skinTextureUrl
await loadPreviewSkin() await loadPreviewSkin()
@@ -361,7 +362,7 @@ async function save() {
let textureUrl: string let textureUrl: string
if (uploadedTextureUrl.value) { if (uploadedTextureUrl.value) {
textureUrl = uploadedTextureUrl.value textureUrl = uploadedTextureUrl.value.original
} else { } else {
textureUrl = currentSkin.value!.texture textureUrl = currentSkin.value!.texture
} }
-17
View File
@@ -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 })
}
+79
View File
@@ -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,
}
})
}
@@ -5,18 +5,25 @@
*/ */
import { invoke } from '@tauri-apps/api/core' 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') 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') 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') 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') return await invoke('plugin:mr-auth|cancel_modrinth_login')
} }
@@ -67,6 +67,8 @@ export type AppSettings = {
skipped_update: string | null skipped_update: string | null
pending_update_toast_for_version: string | null pending_update_toast_for_version: string | null
auto_download_updates: boolean | null auto_download_updates: boolean | null
version: number
} }
// Get full settings object // Get full settings object
+5
View File
@@ -22,6 +22,11 @@ export interface Skin {
is_equipped: boolean is_equipped: boolean
} }
export interface SkinTextureUrl {
original: string
normalized: string
}
export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[] export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[]
export const DEFAULT_MODELS: Record<string, SkinModel> = { export const DEFAULT_MODELS: Record<string, SkinModel> = {
+6 -5
View File
@@ -7,7 +7,7 @@ export const allowState = ref(false)
export const installState = ref(false) export const installState = ref(false)
export const updateState = ref(false) export const updateState = ref(false)
const currentOS = await getOS() const currentOS = ref('')
const api = `https://git.astralium.su/api/v1/repos/didirus/AstralRinth/releases/latest` const api = `https://git.astralium.su/api/v1/repos/didirus/AstralRinth/releases/latest`
const systems = ['macos', 'windows', 'linux'] const systems = ['macos', 'windows', 'linux']
@@ -28,6 +28,7 @@ export async function getRemote(isDownloadState) {
var releaseTag = null; var releaseTag = null;
var releaseTitle = null; var releaseTitle = null;
var result = false; var result = false;
currentOS.value = await getOS();
try { try {
const response = await fetch(api); const response = await fetch(api);
if (!response.ok) { if (!response.ok) {
@@ -40,7 +41,7 @@ export async function getRemote(isDownloadState) {
releaseTag.textContent = remoteData.tag_name; releaseTag.textContent = remoteData.tag_name;
releaseTitle.textContent = remoteData.name; releaseTitle.textContent = remoteData.name;
} }
if (systems.includes(currentOS.toLowerCase())) { if (systems.includes(currentOS.value.toLowerCase())) {
const localVersion = await getVersion(); const localVersion = await getVersion();
const isUpdateAvailable = !remoteData.tag_name.includes(localVersion); const isUpdateAvailable = !remoteData.tag_name.includes(localVersion);
updateState.value = isUpdateAvailable; updateState.value = isUpdateAvailable;
@@ -54,7 +55,7 @@ export async function getRemote(isDownloadState) {
installState.value = true; installState.value = true;
const builds = remoteData.assets; const builds = remoteData.assets;
const fileName = getInstaller(getExtension(), builds); const fileName = getInstaller(getExtension(), builds);
result = fileName ? await initUpdateLauncher(fileName[1], fileName[0], currentOS, true) : false; result = fileName ? await initUpdateLauncher(fileName[1], fileName[0], currentOS.value, true) : false;
installState.value = false; installState.value = false;
} catch (err) { } catch (err) {
installState.value = false; installState.value = false;
@@ -64,7 +65,7 @@ export async function getRemote(isDownloadState) {
console.log('Remote version is', remoteData.tag_name); console.log('Remote version is', remoteData.tag_name);
console.log('Remote title is', remoteData.name); console.log('Remote title is', remoteData.name);
console.log('Local version is', await getVersion()); console.log('Local version is', await getVersion());
console.log('Operating System is', currentOS); console.log('Operating System is', currentOS.value);
return result; return result;
} catch (error) { } catch (error) {
console.error("Failed to fetch remote releases:", error); console.error("Failed to fetch remote releases:", error);
@@ -91,7 +92,7 @@ function getInstaller(osExtension, builds) {
} }
function getExtension() { function getExtension() {
return systems.find(osName => osName === currentOS.toLowerCase())?.endsWith('macos') return systems.find(osName => osName === currentOS.value.toLowerCase())?.endsWith('macos')
? macosExtensions ? macosExtensions
: windowsExtensions; : windowsExtensions;
} }
@@ -65,6 +65,69 @@
"app.update.reload-to-update": { "app.update.reload-to-update": {
"message": "Reload to install 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": { "instance.add-server.add-and-play": {
"message": "Add and play" "message": "Add and play"
}, },
+13 -11
View File
@@ -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 type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, skinBlobUrlMap } 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 { 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 { import {
equip_skin, equip_skin,
filterDefaultSkins, filterDefaultSkins,
@@ -245,16 +245,18 @@ function openUploadSkinModal(e: MouseEvent) {
function onSkinFileUploaded(buffer: ArrayBuffer) { function onSkinFileUploaded(buffer: ArrayBuffer) {
const fakeEvent = new MouseEvent('click') const fakeEvent = new MouseEvent('click')
normalize_skin_texture(`data:image/png;base64,` + arrayBufferToBase64(buffer)).then( const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer)
(skinTextureNormalized: Uint8Array) => { normalize_skin_texture(originalSkinTexUrl).then((skinTextureNormalized: Uint8Array) => {
const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized) const skinTexUrl: SkinTextureUrl = {
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) { original: originalSkinTexUrl,
editSkinModal.value.restoreWithNewTexture(skinTexUrl) normalized: `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized),
} else { }
editSkinModal.value?.showNew(fakeEvent, skinTexUrl) if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
} editSkinModal.value.restoreWithNewTexture(skinTexUrl)
}, } else {
) editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
}
})
} }
function onUploadCanceled() { function onUploadCanceled() {
@@ -139,9 +139,9 @@
<template #add_content> <PlusIcon /> Add content </template> <template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EditIcon /> Edit </template> <template #edit> <EditIcon /> Edit </template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </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 #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_names><EditIcon />Copy names</template>
<template #copy_slugs><HashIcon />Copy slugs</template> <template #copy_slugs><HashIcon />Copy slugs</template>
<template #copy_links><GlobeIcon />Copy links</template> <template #copy_links><GlobeIcon />Copy links</template>
@@ -92,7 +92,7 @@ import {
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Button, Card } from '@modrinth/ui' 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 { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
@@ -151,17 +151,26 @@ const expandImage = (item, index) => {
function keyListener(e) { function keyListener(e) {
if (expandedGalleryItem.value) { if (expandedGalleryItem.value) {
e.preventDefault()
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault()
hideImage() hideImage()
} else if (e.key === 'ArrowLeft') { } else if (e.key === 'ArrowLeft') {
e.preventDefault()
previousImage() previousImage()
} else if (e.key === 'ArrowRight') { } else if (e.key === 'ArrowRight') {
e.preventDefault()
nextImage() nextImage()
} }
} }
} }
document.addEventListener('keypress', keyListener)
onMounted(() => {
document.addEventListener('keydown', keyListener)
})
onUnmounted(() => {
document.removeEventListener('keydown', keyListener)
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
+7 -1
View File
@@ -148,9 +148,15 @@ fn main() {
} else { } else {
"app-window-state.json".to_string() "app-window-state.json".to_string()
}, },
)
// 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::MAXIMIZED,
) )
.build(), .build(),
) )
.setup(|app| { .setup(|app| {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
+1 -1
View File
@@ -48,7 +48,7 @@
] ]
}, },
"productName": "AstralRinth App", "productName": "AstralRinth App",
"version": "0.10.1201", "version": "0.10.1601",
"mainBinaryName": "AstralRinth App", "mainBinaryName": "AstralRinth App",
"identifier": "AstralRinthApp", "identifier": "AstralRinthApp",
"plugins": { "plugins": {
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"mainBinaryName": "ModrinthApp" "mainBinaryName": "AstralRinthApp"
} }
+1 -1
View File
@@ -7,7 +7,7 @@
"fullscreen": false, "fullscreen": false,
"height": 800, "height": 800,
"resizable": true, "resizable": true,
"title": "Modrinth App", "title": "AstralRinth App",
"width": 1280, "width": 1280,
"minHeight": 700, "minHeight": 700,
"minWidth": 1100, "minWidth": 1100,
+2 -2
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM rust:1.89.0 AS build FROM rust:1.90.0 AS build
WORKDIR /usr/src/daedalus WORKDIR /usr/src/daedalus
COPY . . COPY . .
@@ -14,7 +14,7 @@ RUN --mount=type=cache,target=/usr/src/daedalus/target \
mkdir /daedalus \ mkdir /daedalus \
&& cp /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client && 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.source=https://github.com/modrinth/code
LABEL org.opencontainers.image.title=daedalus LABEL org.opencontainers.image.title=daedalus
+111 -4
View File
@@ -2762,10 +2762,7 @@
}, },
{ {
"_comment": "Replace glfw from 3.3.1 with version from 3.3.2 to prevent stack smashing", "_comment": "Replace glfw from 3.3.1 with version from 3.3.2 to prevent stack smashing",
"match": [ "match": ["org.lwjgl:lwjgl-glfw-natives-linux:3.3.1"],
"org.lwjgl:lwjgl-glfw-natives-linux:3.3.1",
"org.lwjgl:lwjgl-glfw:3.3.1:natives-linux"
],
"override": { "override": {
"downloads": { "downloads": {
"artifact": { "artifact": {
@@ -2776,5 +2773,115 @@
}, },
"name": "org.lwjgl:lwjgl-glfw-natives-linux:3.3.2-lwjgl.1" "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"
}
}
]
}
]
} }
] ]
+2
View File
@@ -186,6 +186,8 @@ pub struct LibraryPatch {
} }
fn fetch_library_patches() -> Result<Vec<LibraryPatch>, Error> { 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"); let patches = include_bytes!("../library-patches.json");
Ok(serde_json::from_slice(patches)?) Ok(serde_json::from_slice(patches)?)
} }
@@ -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. 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: 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:
+2 -3
View File
@@ -2,9 +2,8 @@
::backdrop, ::backdrop,
:root[data-theme='light'], :root[data-theme='light'],
[data-theme='light'] ::backdrop { [data-theme='light'] ::backdrop {
--sl-font-system: --sl-font-system: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--sl-color-white: var(--color-contrast); /* “white” */ --sl-color-white: var(--color-contrast); /* “white” */
--sl-color-gray-1: var(--color-base); --sl-color-gray-1: var(--color-base);
@@ -1,20 +1,23 @@
<template> <template>
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised"> <div class="wrapper relative mb-3 flex w-full justify-center rounded-2xl">
<nuxt-link <AutoLink
:to="flags.enableMedalPromotion ? '/servers?plan&ref=medal' : '/servers'" :to="currentAd.link"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]" :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 <img
:src="`https://cdn-raw.modrinth.com/${flags.enableMedalPromotion ? 'medal-modrinth-servers' : 'modrinth-servers-placeholder'}-light.webp`" :src="currentAd.light"
alt="Host your next server with Modrinth Servers" aria-hidden="true"
:alt="currentAd.description"
class="light-image hidden rounded-[inherit]" class="light-image hidden rounded-[inherit]"
/> />
<img <img
:src="`https://cdn-raw.modrinth.com/${flags.enableMedalPromotion ? 'medal-modrinth-servers' : 'modrinth-servers-placeholder'}-dark.webp`" :src="currentAd.dark"
alt="Host your next server with Modrinth Servers" aria-hidden="true"
:alt="currentAd.description"
class="dark-image rounded-[inherit]" class="dark-image rounded-[inherit]"
/> />
</nuxt-link> </AutoLink>
<div <div
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised" class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
> >
@@ -23,6 +26,8 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { AutoLink } from '@modrinth/ui'
const flags = useFeatureFlags() const flags = useFeatureFlags()
useHead({ 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(() => { onMounted(() => {
window.tude = window.tude || { cmd: [] } window.tude = window.tude || { cmd: [] }
window.Raven = window.Raven || { cmd: [] } window.Raven = window.Raven || { cmd: [] }
@@ -137,10 +161,14 @@ iframe[id^='google_ads_iframe'] {
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.ad-parent { .wrapper {
display: none; display: none;
} }
} }
.wrapper > * {
box-shadow: var(--shadow-card);
}
</style> </style>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -8,6 +8,11 @@
{{ analytics.error.value }} {{ analytics.error.value }}
</div> </div>
</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 v-else class="graphs">
<div class="graphs__vertical-bar"> <div class="graphs__vertical-bar">
<client-only> <client-only>
@@ -419,6 +424,7 @@ const isUsingProjectColors = computed({
const startDate = ref(dayjs().startOf('day')) const startDate = ref(dayjs().startOf('day'))
const endDate = ref(dayjs().endOf('day')) const endDate = ref(dayjs().endOf('day'))
const timeResolution = ref(30) const timeResolution = ref(30)
const isInitialized = ref(false)
onBeforeMount(() => { onBeforeMount(() => {
// Load cached data and range from localStorage - cache. // Load cached data and range from localStorage - cache.
@@ -449,6 +455,8 @@ onMounted(() => {
startDate.value = ranges.startDate startDate.value = ranges.startDate
endDate.value = ranges.endDate endDate.value = ranges.endDate
timeResolution.value = selectedRange.value.timeResolution timeResolution.value = selectedRange.value.timeResolution
isInitialized.value = true
}) })
const internalRange: Ref<RangeObject> = ref(null as unknown as RangeObject) const internalRange: Ref<RangeObject> = ref(null as unknown as RangeObject)
@@ -482,6 +490,7 @@ const analytics = useFetchAllAnalytics(
startDate, startDate,
endDate, endDate,
timeResolution, timeResolution,
isInitialized,
) )
const formattedCategorySubtitle = computed(() => { const formattedCategorySubtitle = computed(() => {
@@ -631,6 +631,7 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', handleKeybinds)
notifications.setNotificationLocation('right') notifications.setNotificationLocation('right')
}) })
@@ -13,14 +13,23 @@ import {
TrashIcon, TrashIcon,
XIcon, XIcon,
} from '@modrinth/assets' } 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 type { Backup } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { computed } from 'vue' import { computed, ref } from 'vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const flags = useFeatureFlags() const flags = useFeatureFlags()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'download' | 'rename' | 'restore' | 'lock' | 'retry'): void (e: 'download' | 'rename' | 'restore' | 'lock' | 'retry'): void
@@ -33,11 +42,13 @@ const props = withDefaults(
preview?: boolean preview?: boolean
kyrosUrl?: string kyrosUrl?: string
jwt?: string jwt?: string
server?: ModrinthServer
}>(), }>(),
{ {
preview: false, preview: false,
kyrosUrl: undefined, kyrosUrl: undefined,
jwt: undefined, jwt: undefined,
server: undefined,
}, },
) )
@@ -124,7 +135,48 @@ const messages = defineMessages({
id: 'servers.backups.item.retry', id: 'servers.backups.item.retry',
defaultMessage: '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> </script>
<template> <template>
<div <div
@@ -192,6 +244,15 @@ const messages = defineMessages({
class="max-w-full" class="max-w-full"
/> />
</div> </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> <template v-else>
<div class="col-span-2"> <div class="col-span-2">
{{ dayjs(backup.created_at).format('MMMM D, YYYY [at] h:mm A') }} {{ dayjs(backup.created_at).format('MMMM D, YYYY [at] h:mm A') }}
@@ -223,34 +284,32 @@ const messages = defineMessages({
</button> </button>
</ButtonStyled> </ButtonStyled>
<template v-else> <template v-else>
<ButtonStyled> <ButtonStyled v-show="!downloading">
<a <button :disabled="!server?.backups" @click="handleDownload">
:class="{
disabled: !kyrosUrl || !jwt,
}"
:href="`https://${kyrosUrl}/modrinth/v0/backups/${backup.id}/download?auth=${jwt}`"
@click="() => emit('download')"
>
<DownloadIcon /> <DownloadIcon />
{{ formatMessage(commonMessages.downloadButton) }} {{ formatMessage(commonMessages.downloadButton) }}
</a> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled circular type="transparent"> <ButtonStyled circular type="transparent">
<OverflowMenu <OverflowMenu
:options="[ :options="[
{ id: 'rename', action: () => emit('rename') }, {
id: 'rename',
action: () => emit('rename'),
disabled: !!restoring || !!downloading,
},
{ {
id: 'restore', id: 'restore',
action: () => emit('restore'), action: () => emit('restore'),
disabled: !!restoring, disabled: !!restoring || !!downloading,
}, },
{ id: 'lock', action: () => emit('lock') }, { id: 'lock', action: () => emit('lock'), disabled: !!restoring || !!downloading },
{ divider: true }, { divider: true },
{ {
id: 'delete', id: 'delete',
color: 'red', color: 'red',
action: () => emit('delete'), action: () => emit('delete'),
disabled: !!restoring, disabled: !!restoring || !!downloading,
}, },
]" ]"
> >
@@ -68,7 +68,11 @@
</ButtonStyled> </ButtonStyled>
<ButtonStyled type="standard" color="brand"> <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"> <div v-if="isTransitionState" class="grid place-content-center">
<LoadingIcon /> <LoadingIcon />
</div> </div>
@@ -122,12 +126,15 @@ import { useStorage } from '@vueuse/core'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
import LoadingIcon from './icons/LoadingIcon.vue' import LoadingIcon from './icons/LoadingIcon.vue'
import PanelSpinner from './PanelSpinner.vue' import PanelSpinner from './PanelSpinner.vue'
import ServerInfoLabels from './ServerInfoLabels.vue' import ServerInfoLabels from './ServerInfoLabels.vue'
import TeleportOverflowMenu from './TeleportOverflowMenu.vue' import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
const flags = useFeatureFlags() const flags = useFeatureFlags()
const { formatMessage } = useVIntl()
interface PowerAction { interface PowerAction {
action: ServerPowerAction action: ServerPowerAction
@@ -142,6 +149,7 @@ const props = defineProps<{
serverName?: string serverName?: string
serverData: object serverData: object
uptimeSeconds: number uptimeSeconds: number
backupInProgress?: BackupInProgressReason
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -163,7 +171,11 @@ const dontAskAgain = ref(false)
const startingDelay = ref(false) const startingDelay = ref(false)
const canTakeAction = computed( const canTakeAction = computed(
() => !props.isActioning && !startingDelay.value && !isTransitionState.value, () =>
!props.isActioning &&
!startingDelay.value &&
!isTransitionState.value &&
!props.backupInProgress,
) )
const isRunning = computed(() => serverState.value === 'running') const isRunning = computed(() => serverState.value === 'running')
const isTransitionState = computed(() => const isTransitionState = computed(() =>
@@ -158,11 +158,20 @@ const currentPlanFromSubscription = computed<ServerPlan | undefined>(() => {
: 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> { async function initiatePayment(body: any): Promise<any> {
if (subscription.value) { if (subscription.value) {
const transformedBody = { const transformedBody = {
interval: body.charge?.interval, interval: body.charge?.interval,
payment_method: body.id, payment_method: body.type === 'confirmation_token' ? body.token : body.id,
product: body.charge?.product_id, product: body.charge?.product_id,
region: body.metadata?.server_region, region: body.metadata?.server_region,
} }
@@ -247,7 +256,7 @@ async function open(id?: string) {
subscription.value = null subscription.value = null
} }
purchaseModal.value?.show('quarterly') purchaseModal.value?.show(currentInterval.value)
} }
defineExpose({ defineExpose({
@@ -11,12 +11,35 @@ export class BackupsModule extends ServerModule {
} }
async create(backupName: string): Promise<string> { async create(backupName: string): Promise<string> {
const response = await useServersFetch<{ id: string }>(`servers/${this.serverId}/backups`, { const tempId = `temp-${Date.now()}-${Math.random().toString(36).substring(7)}`
method: 'POST', const tempBackup: Backup = {
body: { name: backupName }, id: tempId,
}) name: backupName,
await this.fetch() // Refresh this module created_at: new Date().toISOString(),
return response.id 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> { async rename(backupId: string, newName: string): Promise<void> {
@@ -24,35 +47,47 @@ export class BackupsModule extends ServerModule {
method: 'POST', method: 'POST',
body: { name: newName }, body: { name: newName },
}) })
await this.fetch() // Refresh this module await this.fetch()
} }
async delete(backupId: string): Promise<void> { async delete(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}`, { await useServersFetch(`servers/${this.serverId}/backups/${backupId}`, {
method: 'DELETE', method: 'DELETE',
}) })
await this.fetch() // Refresh this module await this.fetch()
} }
async restore(backupId: string): Promise<void> { async restore(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/restore`, { const backup = this.data.find((b) => b.id === backupId)
method: 'POST', if (backup) {
}) if (!backup.task) backup.task = {}
await this.fetch() // Refresh this module backup.task.restore = { progress: 0, state: 'ongoing' }
}
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> { async lock(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/lock`, { await useServersFetch(`servers/${this.serverId}/backups/${backupId}/lock`, {
method: 'POST', method: 'POST',
}) })
await this.fetch() // Refresh this module await this.fetch()
} }
async unlock(backupId: string): Promise<void> { async unlock(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/unlock`, { await useServersFetch(`servers/${this.serverId}/backups/${backupId}/unlock`, {
method: 'POST', method: 'POST',
}) })
await this.fetch() // Refresh this module await this.fetch()
} }
async retry(backupId: string): Promise<void> { async retry(backupId: string): Promise<void> {
@@ -71,4 +106,87 @@ export class BackupsModule extends ServerModule {
async getAutoBackup(): Promise<AutoBackupSettings> { async getAutoBackup(): Promise<AutoBackupSettings> {
return await useServersFetch(`servers/${this.serverId}/autobackup`) 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' method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
contentType?: string contentType?: string
body?: Record<string, any> body?: Record<string, any>
version?: number version?: number | 'internal'
override?: { override?: {
url?: string url?: string
token?: string token?: string
@@ -30,7 +30,7 @@ export async function useServersFetch<T>(
'[Modrinth Servers] Cannot fetch without auth', '[Modrinth Servers] Cannot fetch without auth',
10000, 10000,
) )
throw new ModrinthServerError('Missing auth token', 401, error, module) throw new ModrinthServerError('Missing auth token', 401, error, module, undefined, undefined)
} }
const { const {
@@ -52,7 +52,14 @@ export async function useServersFetch<T>(
'[Modrinth Servers] Circuit breaker open - too many recent failures', '[Modrinth Servers] Circuit breaker open - too many recent failures',
503, 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) { 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', '[Modrinth Servers] Cannot fetch without base url. Make sure to set a PYRO_BASE_URL in environment variables',
10001, 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}` const versionString = `v${version}`
@@ -82,7 +96,9 @@ export async function useServersFetch<T>(
? `https://${newOverrideUrl}/${path.replace(/^\//, '')}` ? `https://${newOverrideUrl}/${path.replace(/^\//, '')}`
: version === 0 : version === 0
? `${base}/modrinth/v${version}/${path.replace(/^\//, '')}` ? `${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> = { const headers: Record<string, string> = {
'User-Agent': 'Modrinth/1.0 (https://modrinth.com)', 'User-Agent': 'Modrinth/1.0 (https://modrinth.com)',
@@ -177,6 +193,7 @@ export async function useServersFetch<T>(
fetchError, fetchError,
module, module,
v1Error, v1Error,
error.data,
) )
} }
@@ -198,6 +215,8 @@ export async function useServersFetch<T>(
undefined, undefined,
fetchError, fetchError,
module, module,
undefined,
undefined,
) )
} }
} }
@@ -210,7 +229,14 @@ export async function useServersFetch<T>(
statusCode, statusCode,
lastError, 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( const fetchError = new ModrinthServersFetchError(
@@ -218,5 +244,12 @@ export async function useServersFetch<T>(
undefined, undefined,
lastError || 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,
)
} }
+66 -2
View File
@@ -82,6 +82,23 @@
</ButtonStyled> </ButtonStyled>
</template> </template>
</PagewideBanner> </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"> <PagewideBanner v-if="showTaxComplianceBanner" variant="warning">
<template #title> <template #title>
<span>{{ formatMessage(taxBannerMessages.title) }}</span> <span>{{ formatMessage(taxBannerMessages.title) }}</span>
@@ -444,6 +461,12 @@
link: '/admin/servers/notices', link: '/admin/servers/notices',
shown: isAdmin(auth.user), shown: isAdmin(auth.user),
}, },
{
id: 'servers-nodes',
color: 'primary',
link: '/admin/servers/nodes',
shown: isAdmin(auth.user),
},
]" ]"
> >
<ModrinthIcon aria-hidden="true" /> <ModrinthIcon aria-hidden="true" />
@@ -463,6 +486,7 @@
<template #servers-notices> <template #servers-notices>
<IssuesIcon aria-hidden="true" /> {{ formatMessage(messages.manageServerNotices) }} <IssuesIcon aria-hidden="true" /> {{ formatMessage(messages.manageServerNotices) }}
</template> </template>
<template #servers-nodes> <ServerIcon aria-hidden="true" /> Server Nodes </template>
</OverflowMenu> </OverflowMenu>
</ButtonStyled> </ButtonStyled>
<ButtonStyled type="transparent"> <ButtonStyled type="transparent">
@@ -851,6 +875,7 @@ import {
LogInIcon, LogInIcon,
LogOutIcon, LogOutIcon,
MastodonIcon, MastodonIcon,
MessageIcon,
ModrinthIcon, ModrinthIcon,
MoonIcon, MoonIcon,
OrganizationIcon, OrganizationIcon,
@@ -918,7 +943,15 @@ const showTaxComplianceBanner = computed(() => {
const thresholdMet = (bal.withdrawn_ytd ?? 0) >= 600 const thresholdMet = (bal.withdrawn_ytd ?? 0) >= 600
const status = bal.form_completion_status ?? 'unknown' const status = bal.form_completion_status ?? 'unknown'
const isComplete = status === 'complete' 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({ const taxBannerMessages = defineMessages({
@@ -929,7 +962,7 @@ const taxBannerMessages = defineMessages({
description: { description: {
id: 'layout.banner.tax.description', id: 'layout.banner.tax.description',
defaultMessage: defaultMessage:
'Youve 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: { action: {
id: 'layout.banner.tax.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) const taxFormModalRef = ref(null)
function openTaxForm(e) { function openTaxForm(e) {
if (taxFormModalRef.value && taxFormModalRef.value.startTaxForm) { if (taxFormModalRef.value && taxFormModalRef.value.startTaxForm) {
@@ -1732,6 +1781,7 @@ const footerLinks = [
@media screen and (min-width: 354px) { @media screen and (min-width: 354px) {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
@media screen and (min-width: 674px) { @media screen and (min-width: 674px) {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
@@ -1926,10 +1976,12 @@ const footerLinks = [
width: 25rem; width: 25rem;
height: 25rem; height: 25rem;
} }
.animation-ring-2 { .animation-ring-2 {
width: 50rem; width: 50rem;
height: 50rem; height: 50rem;
} }
.animation-ring-3 { .animation-ring-3 {
width: 100rem; width: 100rem;
height: 100rem; height: 100rem;
@@ -1958,15 +2010,19 @@ const footerLinks = [
0% { 0% {
rotate: 0deg; rotate: 0deg;
} }
25% { 25% {
rotate: calc(1deg * (var(--_r-count) - 20)); rotate: calc(1deg * (var(--_r-count) - 20));
} }
50% { 50% {
rotate: 0deg; rotate: 0deg;
} }
75% { 75% {
rotate: calc(-1deg * (var(--_r-count) - 20)); rotate: calc(-1deg * (var(--_r-count) - 20));
} }
100% { 100% {
rotate: 0deg; rotate: 0deg;
} }
@@ -1976,15 +2032,19 @@ const footerLinks = [
0% { 0% {
translate: 0; translate: 0;
} }
25% { 25% {
translate: calc(2px * (var(--_r-count) - 20)); translate: calc(2px * (var(--_r-count) - 20));
} }
50% { 50% {
translate: 0; translate: 0;
} }
75% { 75% {
translate: calc(-2px * (var(--_r-count) - 20)); translate: calc(-2px * (var(--_r-count) - 20));
} }
100% { 100% {
translate: 0; translate: 0;
} }
@@ -1994,15 +2054,19 @@ const footerLinks = [
0% { 0% {
transform: translateY(0); transform: translateY(0);
} }
25% { 25% {
transform: translateY(calc(2px * (var(--_r-count) - 20))); transform: translateY(calc(2px * (var(--_r-count) - 20)));
} }
50% { 50% {
transform: translateY(0); transform: translateY(0);
} }
75% { 75% {
transform: translateY(calc(-2px * (var(--_r-count) - 20))); transform: translateY(calc(-2px * (var(--_r-count) - 20)));
} }
100% { 100% {
transform: translateY(0); transform: translateY(0);
} }
+64 -10
View File
@@ -924,11 +924,20 @@
"message": "Complete tax form" "message": "Complete tax form"
}, },
"layout.banner.tax.description": { "layout.banner.tax.description": {
"message": "Youve 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": { "layout.banner.tax.title": {
"message": "Tax form required" "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": { "layout.banner.verify-email.action": {
"message": "Re-send verification email" "message": "Re-send verification email"
}, },
@@ -1103,6 +1112,12 @@
"moderation.technical.search.placeholder": { "moderation.technical.search.placeholder": {
"message": "Search tech reviews..." "message": "Search tech reviews..."
}, },
"profile.bio.fallback.creator": {
"message": "A Modrinth creator."
},
"profile.bio.fallback.user": {
"message": "A Modrinth user."
},
"profile.button.billing": { "profile.button.billing": {
"message": "Manage user billing" "message": "Manage user billing"
}, },
@@ -1115,18 +1130,48 @@
"profile.button.manage-projects": { "profile.button.manage-projects": {
"message": "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": { "profile.error.not-found": {
"message": "User not found" "message": "User not found"
}, },
"profile.joined-at": {
"message": "Joined <date>{ago}</date>"
},
"profile.label.badges": { "profile.label.badges": {
"message": "Badges" "message": "Badges"
}, },
"profile.label.collection": {
"message": "Collection"
},
"profile.label.details": { "profile.label.details": {
"message": "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": { "profile.label.no-collections": {
"message": "This user has no collections!" "message": "This user has no collections!"
}, },
@@ -1142,18 +1187,21 @@
"profile.label.organizations": { "profile.label.organizations": {
"message": "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": { "profile.meta.description": {
"message": "Download {username}'s projects on Modrinth" "message": "Download {username}'s projects on Modrinth"
}, },
"profile.meta.description-with-bio": { "profile.meta.description-with-bio": {
"message": "{bio} - Download {username}'s projects on Modrinth" "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": { "profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> project follower} other {<stat>{count}</stat> project followers}}" "message": "{count, plural, one {<stat>{count}</stat> project follower} other {<stat>{count}</stat> project followers}}"
}, },
@@ -1886,6 +1934,12 @@
"servers.backups.item.creating-backup": { "servers.backups.item.creating-backup": {
"message": "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": { "servers.backups.item.failed-to-create-backup": {
"message": "Failed to create backup" "message": "Failed to create backup"
}, },
+86 -1
View File
@@ -97,6 +97,41 @@
</div> </div>
</div> </div>
</NewModal> </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="page experimental-styles-within">
<div <div
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4" class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
@@ -140,6 +175,7 @@
</div> </div>
</div> </div>
<div v-if="subscription.metadata?.id" class="flex flex-col items-end gap-2"> <div v-if="subscription.metadata?.id" class="flex flex-col items-end gap-2">
<CopyCode :text="subscription.metadata.id" />
<ButtonStyled <ButtonStyled
v-if=" v-if="
subscription.metadata?.type === 'pyro' || subscription.metadata?.type === 'medal' subscription.metadata?.type === 'pyro' || subscription.metadata?.type === 'medal'
@@ -153,7 +189,12 @@
<ServerIcon /> Server panel <ExternalIcon class="h-4 w-4" /> <ServerIcon /> Server panel <ExternalIcon class="h-4 w-4" />
</nuxt-link> </nuxt-link>
</ButtonStyled> </ButtonStyled>
<CopyCode :text="subscription.metadata.id" /> <ButtonStyled>
<button @click="showCreditModal(subscription)">
<CurrencyIcon />
Credit
</button>
</ButtonStyled>
</div> </div>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@@ -292,6 +333,7 @@ import {
useRelativeTime, useRelativeTime,
} from '@modrinth/ui' } from '@modrinth/ui'
import { formatCategory, formatPrice } from '@modrinth/utils' import { formatCategory, formatPrice } from '@modrinth/utils'
import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue' import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
@@ -370,6 +412,11 @@ const modifying = ref(false)
const modifyModal = ref() const modifyModal = ref()
const cancel = ref(false) const cancel = ref(false)
const crediting = ref(false)
const creditModal = ref()
const creditDays = ref(7)
const creditSendEmail = ref(true)
function showRefundModal(charge) { function showRefundModal(charge) {
selectedCharge.value = charge selectedCharge.value = charge
refundType.value = 'full' refundType.value = 'full'
@@ -385,6 +432,44 @@ function showModifyModal(charge, subscription) {
modifyModal.value.show() 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() { async function refundCharge() {
refunding.value = true refunding.value = true
try { 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>
+24 -17
View File
@@ -18,12 +18,17 @@
<section class="auth-form"> <section class="auth-form">
<p>{{ formatMessage(postVerificationMessages.description) }}</p> <p>{{ formatMessage(postVerificationMessages.description) }}</p>
<NuxtLink v-if="auth.user" class="btn" link="/settings/account"> <ButtonStyled v-if="auth.user">
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }} <NuxtLink to="/settings/account">
</NuxtLink> <SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
<NuxtLink v-else to="/auth/sign-in" class="btn btn-primary continue-btn centered-btn"> </NuxtLink>
{{ formatMessage(messages.signIn) }} <RightArrowIcon /> </ButtonStyled>
</NuxtLink> <ButtonStyled v-else>
<NuxtLink to="/auth/sign-in">
{{ formatMessage(messages.signIn) }}
<RightArrowIcon />
</NuxtLink>
</ButtonStyled>
</section> </section>
</template> </template>
@@ -40,24 +45,26 @@
</template> </template>
</p> </p>
<button <ButtonStyled v-if="auth.user" color="brand">
v-if="auth.user" <button @click="handleResendEmailVerification">
class="btn btn-primary continue-btn" {{ formatMessage(failedVerificationMessages.action) }}
@click="handleResendEmailVerification" <RightArrowIcon />
> </button>
{{ formatMessage(failedVerificationMessages.action) }} <RightArrowIcon /> </ButtonStyled>
</button>
<NuxtLink v-else to="/auth/sign-in" class="btn btn-primary continue-btn centered-btn"> <ButtonStyled v-else color="brand">
{{ formatMessage(messages.signIn) }} <RightArrowIcon /> <NuxtLink to="/auth/sign-in">
</NuxtLink> {{ formatMessage(messages.signIn) }}
<RightArrowIcon />
</NuxtLink>
</ButtonStyled>
</section> </section>
</template> </template>
</div> </div>
</template> </template>
<script setup> <script setup>
import { RightArrowIcon, SettingsIcon } from '@modrinth/assets' import { RightArrowIcon, SettingsIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui' import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -146,7 +146,7 @@
before proceeding. before proceeding.
</p> </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. You have withdrawn over $600 this year. To continue withdrawing, you must complete a tax form.
</p> </p>
@@ -4,7 +4,7 @@ import { articles as rawArticles } from '@modrinth/blog'
import { Avatar, ButtonStyled } from '@modrinth/ui' import { Avatar, ButtonStyled } from '@modrinth/ui'
import type { User } from '@modrinth/utils' import type { User } from '@modrinth/utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { computed } from 'vue' import { computed, onMounted } from 'vue'
import NewsletterButton from '~/components/ui/NewsletterButton.vue' import NewsletterButton from '~/components/ui/NewsletterButton.vue'
import ShareArticleButtons from '~/components/ui/ShareArticleButtons.vue' import ShareArticleButtons from '~/components/ui/ShareArticleButtons.vue'
@@ -74,6 +74,36 @@ useSeoMeta({
twitterCard: 'summary_large_image', twitterCard: 'summary_large_image',
twitterImage: () => thumbnailPath.value, 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> </script>
<template> <template>
@@ -181,14 +211,19 @@ useSeoMeta({
padding: 0; padding: 0;
} }
ul, ul > li:not(:last-child),
ol > li:not(:last-child) { ol > li:not(:last-child) {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
ul, ul,
ol { ol {
p { > li > p {
margin-top: 0;
margin-bottom: 0;
}
> li > p:not(:last-child) {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
} }
@@ -220,20 +255,22 @@ useSeoMeta({
h2 { h2 {
font-size: 1.25rem; font-size: 1.25rem;
margin-top: 1.5rem;
@media (min-width: 640px) { @media (min-width: 640px) {
font-size: 1.5rem; font-size: 1.5rem;
} }
} }
h3 { h3 {
font-size: 1.125rem; font-size: 1rem;
margin-top: 1.25rem;
@media (min-width: 640px) { @media (min-width: 640px) {
font-size: 1.25rem; font-size: 1.125rem;
} }
} }
p { p {
margin-bottom: 1.25rem; margin-bottom: 0.75rem;
font-size: 0.875rem; font-size: 0.875rem;
@media (min-width: 640px) { @media (min-width: 640px) {
font-size: 1rem; 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, > img,
> :has(img:first-child:last-child) { > .video-wrapper,
> :has(img:first-child:last-child),
> :has(video:first-child:last-child) {
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
@@ -151,6 +151,7 @@
:server-name="serverData.name" :server-name="serverData.name"
:server-data="serverData" :server-data="serverData"
:uptime-seconds="uptimeSeconds" :uptime-seconds="uptimeSeconds"
:backup-in-progress="backupInProgress"
@action="sendPowerAction" @action="sendPowerAction"
/> />
</div> </div>
@@ -354,7 +355,7 @@
> >
<h2 class="m-0 text-lg font-extrabold text-contrast">Server data</h2> <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">{{ <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> }}</pre>
</div> </div>
</template> </template>
@@ -759,9 +760,14 @@ const handleWebSocketMessage = (data: WSEvent) => {
curBackup.task = {} curBackup.task = {}
} }
curBackup.task[data.task] = { const currentState = curBackup.task[data.task]?.state
progress: data.progress, const shouldUpdate = !(currentState === 'ongoing' && data.state === 'unchanged')
state: data.state,
if (shouldUpdate) {
curBackup.task[data.task] = {
progress: data.progress,
state: data.state,
}
} }
curBackup.ongoing = data.task === 'create' && data.state === 'ongoing' curBackup.ongoing = data.task === 'create' && data.state === 'ongoing'
@@ -1037,7 +1043,10 @@ const nodeUnavailableDetails = computed(() => [
}, },
{ {
label: 'Node', label: 'Node',
value: server.general?.datacenter ?? 'Unknown', value:
server.moduleErrors?.general?.error.responseData?.hostname ??
server.general?.datacenter ??
'Unknown',
type: 'inline' as const, type: 'inline' as const,
}, },
{ {
@@ -1277,6 +1286,7 @@ useHead({
opacity: 0; opacity: 0;
transform: translateX(1rem); transform: translateX(1rem);
} }
100% { 100% {
opacity: 1; opacity: 1;
transform: none; transform: none;
@@ -105,6 +105,7 @@
v-for="backup in backups" v-for="backup in backups"
:key="`backup-${backup.id}`" :key="`backup-${backup.id}`"
:backup="backup" :backup="backup"
:server="props.server"
:kyros-url="props.server.general?.node.instance" :kyros-url="props.server.general?.node.instance"
:jwt="props.server.general?.node.token" :jwt="props.server.general?.node.token"
@download="() => triggerDownloadAnimation()" @download="() => triggerDownloadAnimation()"
+112 -36
View File
@@ -16,7 +16,7 @@
<ButtonStyled> <ButtonStyled>
<button @click="cancelRoleEdit"> <button @click="cancelRoleEdit">
<XIcon /> <XIcon />
Cancel {{ formatMessage(commonMessages.cancelButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled color="brand"> <ButtonStyled color="brand">
@@ -25,9 +25,11 @@
@click="saveRoleEdit" @click="saveRoleEdit"
> >
<template v-if="isSavingRole"> <template v-if="isSavingRole">
<SpinnerIcon class="animate-spin" /> Saving... <SpinnerIcon class="animate-spin" /> {{ formatMessage(messages.savingLabel) }}
</template>
<template v-else>
<SaveIcon /> {{ formatMessage(commonMessages.saveChangesButton) }}
</template> </template>
<template v-else> <SaveIcon /> Save changes </template>
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@@ -36,10 +38,16 @@
<NewModal v-if="auth.user && isStaff(auth.user)" ref="userDetailsModal" header="User details"> <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-3">
<div class="flex flex-col gap-1"> <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> <div>
<span <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" class="flex w-fit items-center gap-1"
> >
<span>{{ user.email }}</span> <span>{{ user.email }}</span>
@@ -50,12 +58,16 @@
</div> </div>
<div class="flex flex-col gap-1"> <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> <span>{{ user.auth_providers.join(', ') }}</span>
</div> </div>
<div class="flex flex-col gap-1"> <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> <span>
<template v-if="user.payout_data?.paypal_address"> <template v-if="user.payout_data?.paypal_address">
Paypal ({{ user.payout_data.paypal_address }} - {{ user.payout_data.paypal_country }}) Paypal ({{ user.payout_data.paypal_address }} - {{ user.payout_data.paypal_country }})
@@ -70,16 +82,22 @@
</div> </div>
<div class="flex flex-col gap-1"> <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> <span>
{{ user.has_password ? 'Yes' : 'No' }} {{
user.has_password ? formatMessage(messages.yesLabel) : formatMessage(messages.noLabel)
}}
</span> </span>
</div> </div>
<div class="flex flex-col gap-1"> <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> <span>
{{ user.has_totp ? 'Yes' : 'No' }} {{ user.has_totp ? formatMessage(messages.yesLabel) : formatMessage(messages.noLabel) }}
</span> </span>
</div> </div>
</div> </div>
@@ -98,8 +116,8 @@
user.bio user.bio
? user.bio ? user.bio
: projects.length === 0 : projects.length === 0
? 'A Modrinth user.' ? formatMessage(messages.bioFallbackUser)
: 'A Modrinth creator.' : formatMessage(messages.bioFallbackCreator)
}} }}
</template> </template>
<template #stats> <template #stats>
@@ -107,16 +125,22 @@
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold" 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" /> <BoxIcon class="h-6 w-6 text-secondary" />
{{ formatCompactNumber(projects?.length || 0) }} {{
projects formatMessage(messages.profileProjectsLabel, {
count: formatCompactNumber(projects?.length || 0),
})
}}
</div> </div>
<div <div
v-tooltip="sumDownloads.toLocaleString()" v-tooltip="sumDownloads.toLocaleString()"
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold" 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" /> <DownloadIcon class="h-6 w-6 text-secondary" />
{{ formatCompactNumber(sumDownloads) }} {{
downloads formatMessage(messages.profileDownloadsLabel, {
count: formatCompactNumber(sumDownloads),
})
}}
</div> </div>
<div <div
v-tooltip=" v-tooltip="
@@ -128,7 +152,7 @@
class="flex items-center gap-2 font-semibold" class="flex items-center gap-2 font-semibold"
> >
<CalendarIcon class="h-6 w-6 text-secondary" /> <CalendarIcon class="h-6 w-6 text-secondary" />
Joined {{ formatMessage(messages.profileJoinedLabel) }}
{{ formatRelativeTime(user.created) }} {{ formatRelativeTime(user.created) }}
</div> </div>
</template> </template>
@@ -287,7 +311,7 @@
<h2 class="title">{{ collection.name }}</h2> <h2 class="title">{{ collection.name }}</h2>
<div class="stats"> <div class="stats">
<LibraryIcon aria-hidden="true" /> <LibraryIcon aria-hidden="true" />
Collection {{ formatMessage(messages.collectionLabel) }}
</div> </div>
</div> </div>
</div> </div>
@@ -298,25 +322,27 @@
<div class="stats"> <div class="stats">
<BoxIcon /> <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>
<div class="stats"> <div class="stats">
<template v-if="collection.status === 'listed'"> <template v-if="collection.status === 'listed'">
<GlobeIcon /> <GlobeIcon />
<span> Public </span> <span> {{ formatMessage(commonMessages.publicLabel) }} </span>
</template> </template>
<template v-else-if="collection.status === 'unlisted'"> <template v-else-if="collection.status === 'unlisted'">
<LinkIcon /> <LinkIcon />
<span> Unlisted </span> <span> {{ formatMessage(commonMessages.unlistedLabel) }} </span>
</template> </template>
<template v-else-if="collection.status === 'private'"> <template v-else-if="collection.status === 'private'">
<LockIcon /> <LockIcon />
<span> Private </span> <span> {{ formatMessage(commonMessages.privateLabel) }} </span>
</template> </template>
<template v-else-if="collection.status === 'rejected'"> <template v-else-if="collection.status === 'rejected'">
<XIcon /> <XIcon />
<span> Rejected </span> <span> {{ formatMessage(commonMessages.rejectedLabel) }} </span>
</template> </template>
</div> </div>
</div> </div>
@@ -449,25 +475,75 @@ const formatRelativeTime = useRelativeTime()
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const messages = defineMessages({ const messages = defineMessages({
profileProjectsStats: { profileProjectsLabel: {
id: 'profile.stats.projects', id: 'profile.label.projects',
defaultMessage: defaultMessage: '{count} {count, plural, one {project} other {projects}}',
'{count, plural, one {<stat>{count}</stat> project} other {<stat>{count}</stat> projects}}',
}, },
profileDownloadsStats: { profileDownloadsLabel: {
id: 'profile.stats.downloads', id: 'profile.label.downloads',
defaultMessage: defaultMessage: '{count} {count, plural, one {download} other {downloads}}',
'{count, plural, one {<stat>{count}</stat> project download} other {<stat>{count}</stat> project 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: { profileProjectsFollowersStats: {
id: 'profile.stats.projects-followers', id: 'profile.stats.projects-followers',
defaultMessage: defaultMessage:
'{count, plural, one {<stat>{count}</stat> project follower} other {<stat>{count}</stat> project followers}}', '{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: { profileUserId: {
id: 'profile.user-id', id: 'profile.user-id',
defaultMessage: 'User ID: {id}', defaultMessage: 'User ID: {id}',
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -1,5 +1,12 @@
{ {
"articles": [ "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", "title": "Standing By Our Values",
"summary": "Keeping LGBTQIA+ content visible despite demands from Russia.", "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 { #modrinth-rail-1 {
border-radius: 1rem;
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
@@ -90,10 +90,10 @@ import StyledDoc from '../shared/StyledDoc.vue'
Purpose of Payment Purpose of Payment
</Text> </Text>
<Text class="m-0 text-sm leading-relaxed text-secondary"> <Text class="m-0 text-sm leading-relaxed text-secondary">
This payout reflects revenue earned by the creator through their activity on the Modrinth This payout reflects the creator's earnings from their activity on the Modrinth platform.
platform. Earnings are based on advertising revenue, subscriptions, and/or affiliate Such earnings are based on advertising revenue derived from user engagement with the
commissions tied to the creator's published projects, in accordance with the Rewards Program creator's published projects and/or affiliate commissions in accordance with the Rewards
Terms. Program Terms.
</Text> </Text>
</Section> </Section>
@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
Modrinth account. Modrinth account.
</Text> </Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline" <VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>through our Support Portal</VLink
>. >.
</Text> </Text>
</StyledEmail> </StyledEmail>
@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
sign in to your Modrinth account. sign in to your Modrinth account.
</Text> </Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline" <VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>through our Support Portal</VLink
>. >.
</Text> </Text>
</StyledEmail> </StyledEmail>
@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
{emailchanged.new_email}. {emailchanged.new_email}.
</Text> </Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline" <VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>through our Support Portal</VLink
>. >.
</Text> </Text>
</StyledEmail> </StyledEmail>
@@ -70,9 +70,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
<Text class="text-muted text-base"> <Text class="text-muted text-base">
If this wasn't you, please update your password and review your account security settings. If 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 you cannot do this, contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline" <VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>through our Support Portal</VLink
>. >.
</Text> </Text>
</StyledEmail> </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. A new personal access token, <b>{newpat.token_name}</b>, has been added to your account.
</Text> </Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
If you did not create this token, please contact us immediately by replying to this email or If you did not create this token, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline" <VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>through our Support Portal</VLink
>. >.
</Text> </Text>
</StyledEmail> </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">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"> Your password has been changed on your account. </Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline" <VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>through our Support Portal</VLink
>. >.
</Text> </Text>
</StyledEmail> </StyledEmail>
@@ -18,9 +18,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
Modrinth account.</Text Modrinth account.</Text
> >
<Text class="text-muted text-base"> <Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline" <VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>through our Support Portal</VLink
>. >.
</Text> </Text>
</StyledEmail> </StyledEmail>
@@ -34,8 +34,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
<Text class="text-base"> <Text class="text-base">
If you have any questions about the creator rewards program, please contact support through If you have any questions about the creator rewards program, please contact support through
the the
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink> <VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
or by replying to this email. >.
</Text> </Text>
<Text class="text-base">Thank you for being a creator on Modrinth!</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="Weve added time to your server">
<Heading as="h1" class="mb-2 text-2xl font-bold">Weve 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"> <Text class="text-muted text-base">
Thank you for choosing Modrinth! If you have any questions or need help with your 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 <VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>. >.
</Text> </Text>
@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
need to submit the code generated by your authenticator app. need to submit the code generated by your authenticator app.
</Text> </Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline" <VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>through our Support Portal</VLink
>. >.
</Text> </Text>
</StyledEmail> </StyledEmail>
@@ -18,9 +18,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
At your request, we've removed two-factor authentication from your Modrinth account. At your request, we've removed two-factor authentication from your Modrinth account.
</Text> </Text>
<Text class="text-muted text-base"> <Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline" <VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>through our Support Portal</VLink
>. >.
</Text> </Text>
</StyledEmail> </StyledEmail>
@@ -18,6 +18,7 @@ export default {
// Subscriptions // Subscriptions
'subscription-tax-change': () => import('./account/SubscriptionTaxChange.vue'), 'subscription-tax-change': () => import('./account/SubscriptionTaxChange.vue'),
'subscription-credited': () => import('./account/SubscriptionCredited.vue'),
// Moderation // Moderation
'report-submitted': () => import('./moderation/ReportSubmitted.vue'), 'report-submitted': () => import('./moderation/ReportSubmitted.vue'),
@@ -68,8 +68,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
in error or is abusive, please contact support in error or is abusive, please contact support
<VLink href="https://support.modrinth.com" class="text-green underline"> <VLink href="https://support.modrinth.com" class="text-green underline">
through the Support Portal</VLink through the Support Portal</VLink
> >.
or by replying to this email.
</Text> </Text>
</StyledEmail> </StyledEmail>
</template> </template>
@@ -63,8 +63,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
this was sent in error or is abusive, please contact support this was sent in error or is abusive, please contact support
<VLink href="https://support.modrinth.com" class="text-green underline"> <VLink href="https://support.modrinth.com" class="text-green underline">
through the Support Portal</VLink through the Support Portal</VLink
> >.
or by replying to this email.
</Text> </Text>
</StyledEmail> </StyledEmail>
</template> </template>
@@ -45,8 +45,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
</Button> </Button>
<Text class="text-base"> <Text class="text-base">
If you have questions or believe something isn't correct, you can reply to this email or reach If you have questions or believe something isn't correct, you can reach out via the
out via the
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink <VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>. >.
</Text> </Text>
@@ -53,8 +53,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
<Text class="text-base"> <Text class="text-base">
If you believe this status was applied in error, you can reply in the moderation thread or If you believe this status was applied in error, you can reply in the moderation thread or
contact support through our contact support through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink> <VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
or by replying to this email. >.
</Text> </Text>
<Text class="text-base">Thank you for publishing on Modrinth!</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"> <Text class="text-base">
If you did not initiate this transfer, please contact support immediately through the 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> <VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
or by replying to this email. >.
</Text> </Text>
</StyledEmail> </StyledEmail>
</template> </template>
+13 -1
View File
@@ -307,6 +307,7 @@ export const useFetchAllAnalytics = (
startDate = ref(dayjs().subtract(30, 'days')), startDate = ref(dayjs().subtract(30, 'days')),
endDate = ref(dayjs()), endDate = ref(dayjs()),
timeResolution = ref(1440), timeResolution = ref(1440),
isInitialized = ref(false),
) => { ) => {
const downloadData = ref(null) const downloadData = ref(null)
const viewData = ref(null) const viewData = ref(null)
@@ -388,8 +389,18 @@ export const useFetchAllAnalytics = (
} }
watch( watch(
[() => startDate.value, () => endDate.value, () => timeResolution.value, () => projects.value], [
() => startDate.value,
() => endDate.value,
() => timeResolution.value,
() => projects.value,
() => isInitialized.value,
],
async () => { async () => {
if (!isInitialized.value) {
return
}
const q = { const q = {
start_date: startDate.value.toISOString(), start_date: startDate.value.toISOString(),
end_date: endDate.value.toISOString(), end_date: endDate.value.toISOString(),
@@ -456,5 +467,6 @@ export const useFetchAllAnalytics = (
totalData, totalData,
loading, loading,
error, error,
isInitialized,
} }
} }
+4
View File
@@ -114,6 +114,7 @@ CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=default CLICKHOUSE_PASSWORD=default
CLICKHOUSE_DATABASE=staging_ariadne CLICKHOUSE_DATABASE=staging_ariadne
MAXMIND_ACCOUNT_ID=none
MAXMIND_LICENSE_KEY=none MAXMIND_LICENSE_KEY=none
FLAME_ANVIL_URL=none FLAME_ANVIL_URL=none
@@ -141,4 +142,7 @@ COMPLIANCE_PAYOUT_THRESHOLD=disabled
ANROK_API_KEY=none ANROK_API_KEY=none
ANROK_API_URL=none ANROK_API_URL=none
GOTENBERG_URL=http://labrinth-gotenberg:13000
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
ARCHON_URL=none ARCHON_URL=none
+4
View File
@@ -115,6 +115,7 @@ CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=default CLICKHOUSE_PASSWORD=default
CLICKHOUSE_DATABASE=staging_ariadne CLICKHOUSE_DATABASE=staging_ariadne
MAXMIND_ACCOUNT_ID=none
MAXMIND_LICENSE_KEY=none MAXMIND_LICENSE_KEY=none
FLAME_ANVIL_URL=none FLAME_ANVIL_URL=none
@@ -142,4 +143,7 @@ COMPLIANCE_PAYOUT_THRESHOLD=disabled
ANROK_API_KEY=none ANROK_API_KEY=none
ANROK_API_URL=none ANROK_API_URL=none
GOTENBERG_URL=http://localhost:13000
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
ARCHON_URL=none ARCHON_URL=none
+4
View File
@@ -113,6 +113,10 @@ migrations/20250725230041_reports-closed-status-index.sql
migrations/20250727184120_user-newsletter-subscription-column.sql migrations/20250727184120_user-newsletter-subscription-column.sql
migrations/20250804221014_users-redeemals.sql migrations/20250804221014_users-redeemals.sql
migrations/20250805001654_product-prices-public.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, # Prettier reformats some of the PostgreSQL-specific COPY syntax here,
# which is very likely to break things # which is very likely to break things
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -97,6 +97,16 @@
"ordinal": 18, "ordinal": 18,
"name": "tax_drift_loss?", "name": "tax_drift_loss?",
"type_info": "Int8" "type_info": "Int8"
},
{
"ordinal": 19,
"name": "tax_transaction_version?",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "tax_platform_accounting_time?",
"type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
@@ -123,8 +133,10 @@
true, true,
true, true,
true, true,
true,
true,
true true
] ]
}, },
"hash": "e36e0ac1e2edb73533961a18e913f0b8e4f420a76e511571bb2eed9355771e54" "hash": "372c03a6daf0045f615faa9a6205558cd0ea1d9dba5948e8fa2496ed99de8fea"
} }
@@ -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"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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\t\t\tWHERE\n\t\t\t status = 'succeeded'\n\t\t\t AND tax_platform_id IS NULL\n AND payment_platform_id IS NOT NULL\n\t\t\tORDER BY due ASC\n\t\t\tFOR NO KEY UPDATE SKIP LOCKED\n OFFSET $1\n\t\t\tLIMIT $2\n\t\t\t", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -97,11 +97,20 @@
"ordinal": 18, "ordinal": 18,
"name": "tax_drift_loss?", "name": "tax_drift_loss?",
"type_info": "Int8" "type_info": "Int8"
},
{
"ordinal": 19,
"name": "tax_transaction_version?",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "tax_platform_accounting_time?",
"type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
"Left": [ "Left": [
"Int8",
"Int8" "Int8"
] ]
}, },
@@ -124,8 +133,10 @@
true, true,
true, true,
true, true,
true,
true,
true true
] ]
}, },
"hash": "050e755134f6d1f09de805ae2cd0f7ca8f6efb96be9f070c43db7fd2049af2d2" "hash": "4a0e5c7ebd4565b95fb99983484cec76952f1505a75eb1a006b3ad9b8aa91a51"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -97,6 +97,16 @@
"ordinal": 18, "ordinal": 18,
"name": "tax_drift_loss?", "name": "tax_drift_loss?",
"type_info": "Int8" "type_info": "Int8"
},
{
"ordinal": 19,
"name": "tax_transaction_version?",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "tax_platform_accounting_time?",
"type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
@@ -123,8 +133,10 @@
true, true,
true, true,
true, true,
true,
true,
true true
] ]
}, },
"hash": "9f0c73fabe99d9891faaebdd3518b362437dcdcef9cd9a68b950fba61218bb4d" "hash": "4ed57832b7c02e1f4c683e256455c76e645cde49f95b0e5bfecd3d3d2330ed5c"
} }
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net, tax_amount, tax_platform_id, tax_last_updated, tax_drift_loss, tax_transaction_version, tax_platform_accounting_time)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval,\n payment_platform = EXCLUDED.payment_platform,\n payment_platform_id = EXCLUDED.payment_platform_id,\n parent_charge_id = EXCLUDED.parent_charge_id,\n net = EXCLUDED.net,\n tax_amount = EXCLUDED.tax_amount,\n tax_platform_id = EXCLUDED.tax_platform_id,\n tax_last_updated = EXCLUDED.tax_last_updated,\n price_id = EXCLUDED.price_id,\n amount = EXCLUDED.amount,\n currency_code = EXCLUDED.currency_code,\n charge_type = EXCLUDED.charge_type,\n\t\t\t\t\ttax_drift_loss = EXCLUDED.tax_drift_loss,\n\t\t\t\t\ttax_transaction_version = EXCLUDED.tax_transaction_version,\n\t\t\t\t\ttax_platform_accounting_time = EXCLUDED.tax_platform_accounting_time\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Int8",
"Int8",
"Text",
"Text",
"Varchar",
"Timestamptz",
"Timestamptz",
"Int8",
"Text",
"Text",
"Text",
"Int8",
"Int8",
"Int8",
"Text",
"Timestamptz",
"Int8",
"Int4",
"Timestamptz"
]
},
"nullable": []
},
"hash": "5a972c49ccacf8735ec36d691f1c34b86c33703f984c869346b0c0be1c4a4883"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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 user_id = $1 ORDER BY due DESC", "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 parent_charge_id = $1",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -97,6 +97,16 @@
"ordinal": 18, "ordinal": 18,
"name": "tax_drift_loss?", "name": "tax_drift_loss?",
"type_info": "Int8" "type_info": "Int8"
},
{
"ordinal": 19,
"name": "tax_transaction_version?",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "tax_platform_accounting_time?",
"type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
@@ -123,8 +133,10 @@
true, true,
true, true,
true, true,
true,
true,
true true
] ]
}, },
"hash": "7973e569e784f416c1b4f1e6f3b099dca9c0d9c84e55951a730d8c214580e0d6" "hash": "64233913683d187ee6c449eb106bd1a27929e05b497aaea93e9e8f318770c64c"
} }
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE affiliate_codes\n SET created_by = $1\n WHERE created_by = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "6443da83032ef5d6cb907f97fb37ae62351eeeb2ae3b8148cf8a8fd0deb2795a"
}
@@ -0,0 +1,27 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO users_subscriptions_credits\n (subscription_id, user_id, creditor_id, days, previous_due, next_due)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Int8",
"Int4",
"Timestamptz",
"Timestamptz"
]
},
"nullable": [
false
]
},
"hash": "68968755bd5eacce9009d1d873d08ef679e6638e57e4711c2c215f32e691d856"
}
@@ -0,0 +1,143 @@
{
"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 charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n \n\t\t\tWHERE\n\t\t\t status = 'succeeded'\n\t\t\t AND tax_platform_id IS NULL\n AND payment_platform_id IS NOT NULL\n\t\t\tORDER BY due ASC\n\t\t\tFOR NO KEY UPDATE SKIP LOCKED\n OFFSET $1\n\t\t\tLIMIT $2\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "price_id",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "amount",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "currency_code",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "due",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "last_attempt",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "charge_type",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "subscription_id",
"type_info": "Int8"
},
{
"ordinal": 10,
"name": "tax_amount",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "tax_platform_id",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "subscription_interval?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 14,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 15,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 16,
"name": "net?",
"type_info": "Int8"
},
{
"ordinal": 17,
"name": "tax_last_updated?",
"type_info": "Timestamptz"
},
{
"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": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "70236e8be98967070160f703ed0242239eb5a4c6bef3748dac57fa339260c9c1"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM payouts_values_notifications\n WHERE user_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "713034d4968b290a0096e41b9da044f6760683cb89ff39255a177bb025e7638e"
}
@@ -0,0 +1,142 @@
{
"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 charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n \n\t\t\tINNER JOIN users u ON u.id = charges.user_id\n\t\t\tWHERE\n\t\t\t status = 'open'\n\t\t\t AND COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) < NOW() - INTERVAL '1 day'\n\t\t\t AND u.email IS NOT NULL\n\t\t\t AND due - INTERVAL '7 days' > NOW()\n AND due - INTERVAL '30 days' < NOW() -- Due between 7 and 30 days from now\n\t\t\tORDER BY COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) ASC\n\t\t\tFOR NO KEY UPDATE SKIP LOCKED\n\t\t\tLIMIT $1\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "price_id",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "amount",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "currency_code",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "due",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "last_attempt",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "charge_type",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "subscription_id",
"type_info": "Int8"
},
{
"ordinal": 10,
"name": "tax_amount",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "tax_platform_id",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "subscription_interval?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 14,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 15,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 16,
"name": "net?",
"type_info": "Int8"
},
{
"ordinal": 17,
"name": "tax_last_updated?",
"type_info": "Timestamptz"
},
{
"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": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "7d8de27065490edc560b0c81061295ca82f44546527e1a31c03e5bb7a07c1e63"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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 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\n\t\t\t subscription_id = $1\n\t\t\t AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')\n\t\t\tORDER BY due ASC LIMIT 1",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -97,6 +97,16 @@
"ordinal": 18, "ordinal": 18,
"name": "tax_drift_loss?", "name": "tax_drift_loss?",
"type_info": "Int8" "type_info": "Int8"
},
{
"ordinal": 19,
"name": "tax_transaction_version?",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "tax_platform_accounting_time?",
"type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
@@ -123,8 +133,10 @@
true, true,
true, true,
true, true,
true,
true,
true true
] ]
}, },
"hash": "4e8e9f9cb42f90cc17702386fdb78385608f19dae9439cb6a860503600127b04" "hash": "91866517bf34fb8bf31a7a49832b18fca60c293ad349eaec07b573d22a28301c"
} }
@@ -1,130 +0,0 @@
{
"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 subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "price_id",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "amount",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "currency_code",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "due",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "last_attempt",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "charge_type",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "subscription_id",
"type_info": "Int8"
},
{
"ordinal": 10,
"name": "tax_amount",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "tax_platform_id",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "subscription_interval?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 14,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 15,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 16,
"name": "net?",
"type_info": "Int8"
},
{
"ordinal": 17,
"name": "tax_last_updated?",
"type_info": "Timestamptz"
},
{
"ordinal": 18,
"name": "tax_drift_loss?",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
true,
true,
false,
true,
true,
true,
true,
true
]
},
"hash": "9a35729acbba06eafaa205922e4987e082a000ec1b397957650e1332191613ca"
}
@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT us.id, us.user_id, us.price_id, us.interval, us.created, us.status, us.metadata\n FROM users_subscriptions us\n WHERE us.metadata->>'type' = 'pyro' AND us.metadata->>'id' = ANY($1::text[])\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "price_id",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "interval",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "created",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "metadata",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"TextArray"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
true
]
},
"hash": "aafa63e08f9d556dcd55c4687f0323aa502ce9feb3f439a614bb7759dcb03b23"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE payouts_values\n SET user_id = $1\n WHERE user_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "b97afaa6cab8e042ab0117e64b43a8ba3a2c2be461ff9a6309d7e36c3148aeea"
}
@@ -1,32 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net, tax_amount, tax_platform_id, tax_last_updated, tax_drift_loss)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval,\n payment_platform = EXCLUDED.payment_platform,\n payment_platform_id = EXCLUDED.payment_platform_id,\n parent_charge_id = EXCLUDED.parent_charge_id,\n net = EXCLUDED.net,\n tax_amount = EXCLUDED.tax_amount,\n tax_platform_id = EXCLUDED.tax_platform_id,\n tax_last_updated = EXCLUDED.tax_last_updated,\n price_id = EXCLUDED.price_id,\n amount = EXCLUDED.amount,\n currency_code = EXCLUDED.currency_code,\n charge_type = EXCLUDED.charge_type,\n\t\t\t\t\ttax_drift_loss = EXCLUDED.tax_drift_loss\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Int8",
"Int8",
"Text",
"Text",
"Varchar",
"Timestamptz",
"Timestamptz",
"Int8",
"Text",
"Text",
"Text",
"Int8",
"Int8",
"Int8",
"Text",
"Timestamptz",
"Int8"
]
},
"nullable": []
},
"hash": "c0c70ebc3d59a5ab6a4c81e987df178a7828a45ed3134d3336cb59572f40beab"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM affiliate_codes\n WHERE affiliate = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "ca9b41de4618bcf8ff4f6086f658add5d93855f599a48eeb5f1811f14e7fe610"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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 (\n (status = 'open' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\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 status = 'failed' AND due < NOW() - INTERVAL '30 days'\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -97,6 +97,16 @@
"ordinal": 18, "ordinal": 18,
"name": "tax_drift_loss?", "name": "tax_drift_loss?",
"type_info": "Int8" "type_info": "Int8"
},
{
"ordinal": 19,
"name": "tax_transaction_version?",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "tax_platform_accounting_time?",
"type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
@@ -123,8 +133,10 @@
true, true,
true, true,
true, true,
true,
true,
true true
] ]
}, },
"hash": "e2e58113bc3a3db6ffc75b5c5e10acd16403aa0679ef53330f2ce3e8a45f7b9f" "hash": "caf16ed13389398c1ee3456c0e2534310be545293b2693f1c747425295b367a8"
} }

Some files were not shown because too many files have changed in this diff Show More