Merge tag 'v0.10.16' into beta

This commit is contained in:
2025-11-01 14:14:52 +03:00
203 changed files with 6321 additions and 2161 deletions

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

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

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 }}

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 }}

20
.vscode/settings.json vendored
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"
}
} }

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

1209
Cargo.lock generated

File diff suppressed because it is too large Load Diff

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"

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}`"

View File

@@ -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'
}
})
}, },
}) })

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
} }

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 })
}

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,
}
})
}

View File

@@ -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')
} }

View File

@@ -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

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> = {

View File

@@ -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"
}, },

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() {

View File

@@ -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>

View File

@@ -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">

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")]
{ {

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

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"
}
}
]
}
]
} }
] ]

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)?)
} }

View File

@@ -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:

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);

View File

@@ -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>

View File

@@ -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(() => {

View File

@@ -631,6 +631,7 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', handleKeybinds)
notifications.setNotificationLocation('right') notifications.setNotificationLocation('right')
}) })

View File

@@ -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,
}, },
]" ]"
> >

View File

@@ -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(() =>

View File

@@ -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({

View File

@@ -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(),
}
}
} }

View File

@@ -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,
)
} }

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);
} }

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"
}, },

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 {

View File

@@ -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>

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()

View File

@@ -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>

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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()"

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

View File

@@ -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

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'),

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

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,
} }
} }

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

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

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

View File

@@ -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"
} }

View File

@@ -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"
}

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
}

View File

@@ -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"
} }

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
} }

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
} }

View File

@@ -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 \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 '14 days' < NOW() -- Due between 7 and 14 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"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
true,
true,
false,
true,
true,
true,
true,
true
]
},
"hash": "cd18ae8abe81a159a134923957f4cd6d0ba3b1bcbc89df349cf7b5b1897603b8"
}

View File

@@ -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 parent_charge_id = $1", "query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\",\n charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n WHERE 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": "51c542076b4b3811eb12f051294f55827a27f51e65e668525b8b545f570c0bda" "hash": "f0618e69765ba605b1db7f25a233cbf6f13bddc80a9719cbd401395db718b2f2"
} }

View File

@@ -42,7 +42,6 @@ deadpool-redis.workspace = true
dotenvy = { workspace = true } dotenvy = { workspace = true }
either = { workspace = true } either = { workspace = true }
eyre = { workspace = true } eyre = { workspace = true }
flate2 = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
futures-util = { workspace = true } futures-util = { workspace = true }
hex = { workspace = true } hex = { workspace = true }
@@ -69,8 +68,8 @@ image = { workspace = true, features = [
itertools = { workspace = true } itertools = { workspace = true }
json-patch = { workspace = true } json-patch = { workspace = true }
lettre = { workspace = true } lettre = { workspace = true }
maxminddb = { workspace = true }
meilisearch-sdk = { workspace = true, features = ["reqwest"] } meilisearch-sdk = { workspace = true, features = ["reqwest"] }
modrinth-maxmind = { workspace = true }
murmur2 = { workspace = true } murmur2 = { workspace = true }
paste = { workspace = true } paste = { workspace = true }
path-util = { workspace = true } path-util = { workspace = true }
@@ -91,6 +90,7 @@ rust_decimal = { workspace = true, features = [
] } ] }
rust_iso3166 = { workspace = true } rust_iso3166 = { workspace = true }
rust-s3 = { workspace = true } rust-s3 = { workspace = true }
rustls.workspace = true
rusty-money = { workspace = true } rusty-money = { workspace = true }
sentry = { workspace = true } sentry = { workspace = true }
sentry-actix = { workspace = true } sentry-actix = { workspace = true }
@@ -108,9 +108,8 @@ sqlx = { workspace = true, features = [
"postgres", "postgres",
"runtime-tokio", "runtime-tokio",
"rust_decimal", "rust_decimal",
"tls-rustls-ring", "tls-rustls-aws-lc-rs",
] } ] }
tar = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "sync"] } tokio = { workspace = true, features = ["rt-multi-thread", "sync"] }
tokio-stream = { workspace = true } tokio-stream = { workspace = true }
@@ -121,6 +120,9 @@ tracing-ecs = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
url = { workspace = true } url = { workspace = true }
urlencoding = { workspace = true } urlencoding = { workspace = true }
utoipa = { workspace = true }
utoipa-actix-web = { workspace = true }
utoipa-swagger-ui = { workspace = true }
uuid = { workspace = true, features = ["fast-rng", "serde", "v4"] } uuid = { workspace = true, features = ["fast-rng", "serde", "v4"] }
validator = { workspace = true, features = ["derive"] } validator = { workspace = true, features = ["derive"] }
webp = { workspace = true } webp = { workspace = true }

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/labrinth WORKDIR /usr/src/labrinth
COPY . . COPY . .
@@ -17,7 +17,7 @@ RUN --mount=type=cache,target=/usr/src/labrinth/target \
&& cp -r /usr/src/labrinth/apps/labrinth/migrations /labrinth \ && cp -r /usr/src/labrinth/apps/labrinth/migrations /labrinth \
&& cp -r /usr/src/labrinth/apps/labrinth/assets /labrinth && cp -r /usr/src/labrinth/apps/labrinth/assets /labrinth
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=labrinth LABEL org.opencontainers.image.title=labrinth

View File

@@ -3,24 +3,22 @@
-- Inserts 5 dummy users for testing, with slight differences -- Inserts 5 dummy users for testing, with slight differences
-- 'Friend' and 'enemy' function like 'user', but we can use them to simulate 'other' users that may or may not be able to access certain things -- 'Friend' and 'enemy' function like 'user', but we can use them to simulate 'other' users that may or may not be able to access certain things
-- IDs 1-5, 1-5
INSERT INTO users (id, username, email, role) VALUES (1, 'Admin', 'admin@modrinth.com', 'admin');
INSERT INTO users (id, username, email, role) INSERT INTO users (id, username, email, role)
VALUES (2, 'Moderator', 'moderator@modrinth.com', 'moderator'); VALUES ({{user_id::ADMIN}}, 'Admin', 'admin@modrinth.com', 'admin'),
INSERT INTO users (id, username, email, role) VALUES (3, 'User', 'user@modrinth.com', 'developer'); ({{user_id::MODERATOR}}, 'Moderator', 'moderator@modrinth.com', 'moderator'),
INSERT INTO users (id, username, email, role) ({{user_id::USER}}, 'User', 'user@modrinth.com', 'developer'),
VALUES (4, 'Friend', 'friend@modrinth.com', 'developer'); ({{user_id::FRIEND}}, 'Friend', 'friend@modrinth.com', 'developer'),
INSERT INTO users (id, username, email, role) ({{user_id::ENEMY}}, 'Enemy', 'enemy@modrinth.com', 'developer');
VALUES (5, 'Enemy', 'enemy@modrinth.com', 'developer');
-- Full PATs for each user, with different scopes -- Full PATs for each user, with different scopes
-- These are not legal PATs, as they contain all scopes- they mimic permissions of a logged in user -- These are not legal PATs, as they contain all scopes- they mimic permissions of a logged in user
-- IDs: 50-54, o p q r s -- IDs: 50-54, o p q r s
INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (50, 1, 'admin-pat', 'mrp_patadmin', $1, '2030-08-18 15:48:58.435729+00'); INSERT INTO pats (id, user_id, name, access_token, scopes, expires)
INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (51, 2, 'moderator-pat', 'mrp_patmoderator', $1, '2030-08-18 15:48:58.435729+00'); VALUES (50, {{user_id::ADMIN}}, 'admin-pat', '{{pat::ADMIN}}', {{all_scopes}}, '2030-08-18 15:48:58.435729+00'),
INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (52, 3, 'user-pat', 'mrp_patuser', $1, '2030-08-18 15:48:58.435729+00'); (51, {{user_id::MODERATOR}}, 'moderator-pat', '{{pat::MODERATOR}}', {{all_scopes}}, '2030-08-18 15:48:58.435729+00'),
INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (53, 4, 'friend-pat', 'mrp_patfriend', $1, '2030-08-18 15:48:58.435729+00'); (52, {{user_id::USER}}, 'user-pat', '{{pat::USER}}', {{all_scopes}}, '2030-08-18 15:48:58.435729+00'),
INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (54, 5, 'enemy-pat', 'mrp_patenemy', $1, '2030-08-18 15:48:58.435729+00'); (53, {{user_id::FRIEND}}, 'friend-pat', '{{pat::FRIEND}}', {{all_scopes}}, '2030-08-18 15:48:58.435729+00'),
(54, {{user_id::ENEMY}}, 'enemy-pat', '{{pat::ENEMY}}', {{all_scopes}}, '2030-08-18 15:48:58.435729+00');
INSERT INTO loaders (id, loader) VALUES (5, 'fabric'); INSERT INTO loaders (id, loader) VALUES (5, 'fabric');
INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (5, 1); INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (5, 1);
@@ -119,7 +117,7 @@ VALUES (
1, 1,
'oauth_client_alpha', 'oauth_client_alpha',
NULL, NULL,
$1, {{all_scopes}},
'4dbff86cc2ca1bae1e16468a05cb9881c97f1753bce3619034898faa1aabe429955a1bf8ec483d7421fe3c1646613a59ed5441fb0f321389f77f48a879c7b1f1', '4dbff86cc2ca1bae1e16468a05cb9881c97f1753bce3619034898faa1aabe429955a1bf8ec483d7421fe3c1646613a59ed5441fb0f321389f77f48a879c7b1f1',
3 3
); );

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