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:
- name: 🫶 Support Portal
about: Get support using through our portal.
- name: 🫶 Support portal
about: Get support using through our support website.
url: https://support.modrinth.com
- name: 💬 Chat
- name: 💬 Chat on Discord
about: Join our Discord server to chat about Modrinth.
url: https://discord.modrinth.com
- name: 🛣️ Roadmap
about: View our Roadmap. Please do not open issues for items on our roadmap.
url: https://roadmap.modrinth.com
- name: 📚 Documentation
about: Useful documentation about Modrinth's API
url: https://docs.modrinth.com

View File

@@ -11,7 +11,7 @@ concurrency:
jobs:
pull_translations:
name: 'Pull translations from Crowdin'
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
concurrency:
group: i18n-pull:${{ github.ref }}

View File

@@ -18,7 +18,7 @@ concurrency:
jobs:
push_translations:
name: Push sources to Crowdin
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
concurrency:
group: i18n-push:${{ github.ref }}

20
.vscode/settings.json vendored
View File

@@ -11,5 +11,23 @@
"source.fixAll.eslint": "explicit",
"source.organizeImports": "always"
},
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
}
}

View File

@@ -1,5 +1,35 @@
# Architecture
Use TAB instead of spaces.
## Frontend
There are two similar frontends in the Modrinth monorepo, the website (apps/frontend) and the app frontend (apps/app-frontend).
Both use Tailwind v3, and their respective configs can be seen at `tailwind.config.ts` and `tailwind.config.js` respectively.
Both utilize shared and common components from `@modrinth/ui` which can be found at `packages/ui`, and stylings from `@modrinth/assets` which can be found at `packages/assets`.
Both can utilize icons from `@modrinth/assets`, which are automatically generated based on what's available within the `icons` folder of the `packages/assets` directory. You can see the generated icons list in `generated-icons.ts`.
Both have access to our dependency injection framework, examples as seen in `packages/ui/src/providers/`. Ideally any state which is shared between a page and it's subpages should be shared using this dependency injection framework.
### Website (apps/frontend)
Before a pull request can be opened for the website, `pnpm web:fix` and `pnpm web:intl:extract` must be run, otherwise CI will fail.
To run a development version of the frontend, you must first copy over the relevant `.env` template file (prod, staging or local, usually prod) within the `apps/frontend` folder into `apps/frontend/.env`. Then you can run the frontend by running `pnpm web:dev` in the root folder.
### App Frontend (apps/app-frontend)
Before a pull request can be opened for the website, you must CD into the `app-frontend` folder; `pnpm fix` and `pnpm intl:extract` must be run, otherwise CI will fail.
To run a development version of the app frontend, you must first copy over the relevant `.env` template file (prod, staging or local, usually prod) within `packages/app-lib` into `packages/app-lib/.env`. Then you must run the app itself by running `pnpm app:dev` in the root folder.
### Localization
Refer to `.github/instructions/i18n-convert.instructions.md` if the user asks you to perform any i18n conversion work on a component, set of components, pages or sets of pages.
## Labrinth
Labrinth is the backend API service for Modrinth.
@@ -15,6 +45,7 @@ To prepare the sqlx cache, cd into `apps/labrinth` and run `cargo sqlx prepare`.
Read the root `docker-compose.yml` to see what running services are available while developing. Use `docker exec` to access these services.
When the user refers to "performing pre-PR checks", do the following:
- Run clippy as described above
- DO NOT run tests unless explicitly requested (they take a long time)
- Prepare the sqlx cache

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/ariadne",
"packages/daedalus",
"packages/modrinth-maxmind",
"packages/modrinth-util",
"packages/path-util",
]
[workspace.package]
edition = "2024"
rust-version = "1.90.0"
repository = "https://github.com/modrinth/code"
[workspace.dependencies]
actix-cors = "0.7.1"
actix-files = "0.6.6"
actix-http = "3.11.0"
actix-files = "0.6.8"
actix-http = "3.11.2"
actix-multipart = "0.7.2"
actix-rt = "2.10.0"
actix-rt = "2.11.0"
actix-web = "4.11.0"
actix-web-prom = "0.10.0"
actix-ws = "0.3.0"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async-compression = { version = "0.4.27", default-features = false }
async-compression = { version = "0.4.32", default-features = false }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
] }
async-trait = "0.1.88"
async-tungstenite = { version = "0.30.0", default-features = false, features = [
"futures-03-sink",
async-trait = "0.1.89"
async-tungstenite = { version = "0.31.0", default-features = false, features = [
"futures-03-sink"
] }
async-walkdir = "2.1.0"
async_zip = "0.0.17"
async_zip = "0.0.18"
base64 = "0.22.1"
bitflags = "2.9.1"
bytemuck = "1.23.1"
bitflags = "2.9.4"
bytemuck = "1.24.0"
bytes = "1.10.1"
censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.41"
cidre = { version = "0.11.2", default-features = false, features = [
"macos_15_0",
chrono = "0.4.42"
cidre = { version = "0.11.3", default-features = false, features = [
"macos_15_0"
] }
clap = "4.5.43"
clickhouse = "0.13.3"
clap = "4.5.48"
clickhouse = "0.14.0"
color-eyre = "0.6.5"
color-thief = "0.2.2"
console-subscriber = "0.4.1"
const_format = "0.2.34"
daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0"
data-url = "0.3.1"
data-url = "0.3.2"
deadpool-redis = "0.22.0"
derive_more = "2.0.1"
directories = "6.0.0"
dirs = "6.0.0"
discord-rich-presence = "0.2.5"
discord-rich-presence = "1.0.0"
dotenv-build = "0.1.1"
dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
encoding_rs = "0.8.35"
enumset = "1.1.7"
enumset = "1.1.10"
eyre = "0.6.12"
flate2 = "1.1.2"
flate2 = "1.1.4"
fs4 = { version = "0.13.1", default-features = false }
futures = { version = "0.3.31", default-features = false }
futures = "0.3.31"
futures-util = "0.3.31"
heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
hyper = "1.6.0"
hyper = "1.7.0"
hyper-rustls = { version = "0.27.7", default-features = false, features = [
"aws-lc-rs",
"http1",
"native-tokio",
"ring",
"tls12",
] }
hyper-util = "0.1.16"
iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
indexmap = "2.10.0"
hyper-util = "0.1.17"
iana-time-zone = "0.1.64"
image = { version = "0.25.8", default-features = false, features = ["rayon"] }
indexmap = "2.11.4"
indicatif = "0.18.0"
itertools = "0.14.0"
jemalloc_pprof = "0.8.1"
json-patch = { version = "4.0.0", default-features = false }
lettre = { version = "0.11.18", default-features = false, features = [
json-patch = { version = "4.1.0", default-features = false }
lettre = { version = "0.11.19", default-features = false, features = [
"aws-lc-rs",
"builder",
"hostname",
"pool",
"ring",
"rustls",
"rustls-native-certs",
"smtp-transport",
@@ -101,37 +106,40 @@ lettre = { version = "0.11.18", default-features = false, features = [
"tokio1-rustls",
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.29.1", default-features = false }
meilisearch-sdk = { version = "0.30.0", default-features = false }
modrinth-maxmind = { path = "packages/modrinth-maxmind" }
modrinth-util = { path = "packages/modrinth-util" }
murmur2 = "0.1.0"
native-dialog = "0.9.0"
native-dialog = "0.9.2"
notify = { version = "8.2.0", default-features = false }
notify-debouncer-mini = { version = "0.7.0", default-features = false }
p256 = "0.13.2"
paste = "1.0.15"
path-util = { path = "packages/path-util" }
phf = { version = "0.12.1", features = ["macros"] }
png = "0.17.16"
phf = { version = "0.13.1", features = ["macros"] }
png = "0.18.0"
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
quick-xml = "0.38.1"
quick-xml = "0.38.3"
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "0.32.4"
regex = "1.11.1"
reqwest = { version = "0.12.22", default-features = false }
redis = "0.32.7"
regex = "1.12.2"
reqwest = { version = "0.12.24", default-features = false }
rgb = "0.8.52"
rust_decimal = { version = "1.37.2", features = [
rust_decimal = { version = "1.39.0", features = [
"serde-with-float",
"serde-with-str",
"serde-with-str"
] }
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",
"tags",
"tokio-rustls-tls",
] }
rustls = "0.23.32"
rusty-money = "0.4.1"
sentry = { version = "0.42.0", default-features = false, features = [
sentry = { version = "0.45.0", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
@@ -139,37 +147,38 @@ sentry = { version = "0.42.0", default-features = false, features = [
"reqwest",
"rustls",
] }
sentry-actix = "0.42.0"
serde = "1.0.219"
serde_bytes = "0.11.17"
sentry-actix = "0.45.0"
serde = "1.0.228"
serde_bytes = "0.11.19"
serde_cbor = "0.11.2"
serde_ini = "0.2.0"
serde_json = "1.0.142"
serde_with = "3.14.0"
serde_json = "1.0.145"
serde_with = "3.15.0"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
spdx = "0.10.9"
shlex = "1.3.0"
spdx = "0.12.0"
sqlx = { version = "0.8.6", default-features = false }
sysinfo = { version = "0.36.1", default-features = false }
sysinfo = { version = "0.37.2", default-features = false }
tar = "0.4.44"
tauri = "2.7.0"
tauri-build = "2.3.1"
tauri-plugin-deep-link = "2.4.1"
tauri-plugin-dialog = "2.3.2"
tauri-plugin-http = "2.5.1"
tauri-plugin-opener = "2.4.0"
tauri-plugin-os = "2.3.0"
tauri-plugin-single-instance = "2.3.2"
tauri = "2.8.5"
tauri-build = "2.4.1"
tauri-plugin-deep-link = "2.4.3"
tauri-plugin-dialog = "2.4.0"
tauri-plugin-http = "2.5.2"
tauri-plugin-opener = "2.5.0"
tauri-plugin-os = "2.3.1"
tauri-plugin-single-instance = "2.3.4"
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
"rustls-tls",
"zip",
] }
tauri-plugin-window-state = "2.4.0"
tempfile = "3.20.0"
tempfile = "3.23.0"
theseus = { path = "packages/app-lib" }
thiserror = "2.0.12"
thiserror = "2.0.17"
tikv-jemalloc-ctl = "0.6.0"
tikv-jemallocator = "0.6.0"
tokio = "1.47.1"
@@ -180,22 +189,25 @@ tracing = "0.1.41"
tracing-actix-web = { version = "0.7.19", default-features = false }
tracing-ecs = "0.5.0"
tracing-error = "0.2.1"
tracing-subscriber = "0.3.19"
typed-path = "0.11.0"
url = "2.5.4"
tracing-subscriber = "0.3.20"
typed-path = "0.12.0"
url = "2.5.7"
urlencoding = "2.1.3"
uuid = "1.17.0"
utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "decimal"] }
utoipa-actix-web = { version = "0.1.2" }
utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web", "vendored"] }
uuid = "1.18.1"
validator = "0.20.0"
webp = { version = "0.3.0", default-features = false }
webp = { version = "0.3.1", default-features = false }
webview2-com = "0.38.0" # Should be updated in lockstep with wry
whoami = "1.6.0"
windows = "0.61.3"
windows-core = "0.61.2"
whoami = "1.6.1"
windows = "=0.61.3" # Locked on 0.61 until we can update windows-core to 0.62
windows-core = "=0.61.2" # Locked on 0.61 until webview2-com updates to 0.62
winreg = "0.55.0"
woothee = "0.13.0"
yaserde = "0.12.0"
zbus = "5.9.0"
zip = { version = "4.3.0", default-features = false, features = [
zbus = "5.11.0"
zip = { version = "6.0.0", default-features = false, features = [
"bzip2",
"deflate",
"deflate64",
@@ -234,6 +246,7 @@ redundant_clone = "warn"
redundant_feature_names = "warn"
redundant_type_annotations = "warn"
todo = "warn"
uninlined_format_args = "warn"
unnested_or_patterns = "warn"
wildcard_dependencies = "warn"

View File

@@ -4,6 +4,7 @@ import {
ChangeSkinIcon,
CompassIcon,
DownloadIcon,
ExternalIcon,
HomeIcon,
LeftArrowIcon,
LibraryIcon,
@@ -18,6 +19,7 @@ import {
RestoreIcon,
RightArrowIcon,
SettingsIcon,
UserIcon,
WorldIcon,
XIcon,
} from '@modrinth/assets'
@@ -65,7 +67,7 @@ import { debugAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics
import { get_user } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { useFetch } from '@/helpers/fetch.js'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { list } from '@/helpers/profile.js'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
@@ -668,7 +670,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) // [AR Note] If delete this
v-tooltip.right="formatMessage(commonMessages.settingsLabel)"
:to="() => $refs.settingsModal.show()">
<SettingsIcon />
</NavButton>
</NavButton>
</template>
<template v-else>
<NavButton
@@ -677,29 +679,39 @@ provideAppUpdateDownloadProgress(appUpdateDownload) // [AR Note] If delete this
<SettingsIcon />
</NavButton>
</template>
<ButtonStyled v-if="credentials" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'sign-out',
action: () => logOut(),
color: 'danger',
},
]"
direction="left"
>
<Avatar
:src="credentials.user.avatar_url"
:alt="credentials.user.username"
size="32px"
circle
/>
<template #sign-out> <LogOutIcon /> Sign out </template>
</OverflowMenu>
</ButtonStyled>
<NavButton v-else v-tooltip.right="'Sign in'" :to="() => signIn()">
<LogInIcon />
<template #label>Sign in</template>
<OverflowMenu
v-if="credentials"
v-tooltip.right="`Modrinth account`"
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast border-0 cursor-pointer"
:options="[
{
id: 'view-profile',
action: () => openUrl('https://modrinth.com/user/' + credentials.user.username),
},
{
id: 'sign-out',
action: () => logOut(),
color: 'danger',
},
]"
placement="right-end"
>
<Avatar :src="credentials.user.avatar_url" alt="" size="32px" circle />
<template #view-profile>
<UserIcon />
<span class="inline-flex items-center gap-1">
Signed in as
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
<Avatar :src="credentials.user.avatar_url" alt="" size="20px" circle />
{{ credentials.user.username }}
</span>
</span>
<ExternalIcon />
</template>
<template #sign-out> <LogOutIcon /> Sign out </template>
</OverflowMenu>
<NavButton v-else v-tooltip.right="'Sign in to a Modrinth account'" :to="() => signIn()">
<LogInIcon class="text-brand" />
</NavButton>
</div>
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
@@ -837,20 +849,26 @@ provideAppUpdateDownloadProgress(appUpdateDownload) // [AR Note] If delete this
>
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<h3 class="text-lg m-0">Playing as</h3>
<div
class="p-4 pr-1 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
>
<h3 class="text-base text-primary font-medium m-0">Playing as</h3>
<suspense>
<AccountsCard ref="accounts" mode="small" />
</suspense>
</div>
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<div class="py-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<suspense>
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
<FriendsList
:credentials="credentials"
:sign-in="() => signIn()"
:refresh-credentials="fetchCredentials"
/>
</suspense>
</div>
<div v-if="news && news.length > 0" class="pt-4 flex flex-col items-center">
<h3 class="px-4 text-lg m-0 text-left w-full">News</h3>
<div class="px-4 pt-2 space-y-4 flex flex-col items-center w-full">
<div v-if="news && news.length > 0" class="p-4 pr-1 flex flex-col items-center">
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">News</h3>
<div class="space-y-4 flex flex-col items-center w-full">
<NewsArticleCard
v-for="(item, index) in news"
:key="`news-${index}`"

View File

@@ -24,7 +24,7 @@
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
const emit = defineEmits(['menu-closed', 'option-clicked'])
@@ -40,22 +40,27 @@ defineExpose({
item.value = passedItem
options.value = passedOptions
const menuWidth = contextMenu.value.clientWidth
const menuHeight = contextMenu.value.clientHeight
if (menuWidth + event.pageX >= window.innerWidth) {
left.value = event.pageX - menuWidth + 2 + 'px'
} else {
left.value = event.pageX - 2 + 'px'
}
if (menuHeight + event.pageY >= window.innerHeight) {
top.value = event.pageY - menuHeight + 2 + 'px'
} else {
top.value = event.pageY - 2 + 'px'
}
// show to get dimensions
shown.value = true
// then, adjust position if overflowing
nextTick(() => {
const menuWidth = contextMenu.value?.clientWidth || 200
const menuHeight = contextMenu.value?.clientHeight || 100
const minFromEdge = 10
if (event.pageX + menuWidth + minFromEdge >= window.innerWidth) {
left.value = Math.max(minFromEdge, event.pageX - menuWidth - minFromEdge) + 'px'
} else {
left.value = event.pageX + minFromEdge + 'px'
}
if (event.pageY + menuHeight + minFromEdge >= window.innerHeight) {
top.value = Math.max(minFromEdge, event.pageY - menuHeight - minFromEdge) + 'px'
} else {
top.value = event.pageY + minFromEdge + 'px'
}
})
},
})

View File

@@ -1,41 +1,41 @@
<script setup lang="ts">
import {
MailIcon,
MoreVerticalIcon,
SettingsIcon,
TrashIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
injectNotificationManager,
OverflowMenu,
useRelativeTime,
} from '@modrinth/ui'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { computed, onUnmounted, ref, watch } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_user_many } from '@/helpers/cache'
import { friend_listener } from '@/helpers/events'
import { add_friend, friend_statuses, friends, remove_friend } from '@/helpers/friends'
import {
add_friend,
friends,
type FriendWithUserData,
remove_friend,
transformFriends,
} from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
const props = defineProps<{
credentials: unknown | null
credentials: ModrinthCredentials | null
signIn: () => void
}>()
const userCredentials = computed(() => props.credentials)
const search = ref('')
const manageFriendsModal = ref()
const friendInvitesModal = ref()
const username = ref('')
@@ -47,61 +47,64 @@ async function addFriendFromModal() {
await loadFriends()
}
const friendOptions = ref()
async function handleFriendOptions(args) {
switch (args.option) {
case 'remove-friend':
await removeFriend(args.item)
break
async function addFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await add_friend(id).catch(handleError)
await loadFriends()
}
}
async function addFriend(friend: Friend) {
await add_friend(
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
).catch(handleError)
await loadFriends()
async function removeFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await remove_friend(id).catch(handleError)
await loadFriends()
}
}
async function removeFriend(friend: Friend) {
await remove_friend(
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
).catch(handleError)
await loadFriends()
}
const userFriends = ref<FriendWithUserData[]>([])
const sortedFriends = computed<FriendWithUserData[]>(() =>
userFriends.value.slice().sort((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
return 0 // Both are null, equal in sorting
}
if (a.last_updated === null) {
return 1 // `a` is null, move it after `b`
}
if (b.last_updated === null) {
return -1 // `b` is null, move it after `a`
}
// Both are non-null, sort by date
return b.last_updated.diff(a.last_updated)
}),
)
const filteredFriends = computed<FriendWithUserData[]>(() =>
sortedFriends.value.filter((x) =>
x.username.trim().toLowerCase().includes(search.value.trim().toLowerCase()),
),
)
type Friend = {
id: string
friend_id: string | null
status: string | null
last_updated: Dayjs | null
created: Dayjs
username: string
accepted: boolean
online: boolean
avatar: string
}
const userFriends = ref<Friend[]>([])
const acceptedFriends = computed(() =>
userFriends.value
.filter((x) => x.accepted)
.toSorted((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
return 0 // Both are null, equal in sorting
}
if (a.last_updated === null) {
return 1 // `a` is null, move it after `b`
}
if (b.last_updated === null) {
return -1 // `b` is null, move it after `a`
}
// Both are non-null, sort by date
return b.last_updated.diff(a.last_updated)
}),
const activeFriends = computed<FriendWithUserData[]>(() =>
filteredFriends.value.filter((x) => !!x.status && x.online && x.accepted),
)
const onlineFriends = computed<FriendWithUserData[]>(() =>
filteredFriends.value.filter((x) => x.online && !x.status && x.accepted),
)
const offlineFriends = computed<FriendWithUserData[]>(() =>
filteredFriends.value.filter((x) => !x.online && x.accepted),
)
const pendingFriends = computed(() =>
userFriends.value.filter((x) => !x.accepted).toSorted((a, b) => b.created.diff(a.created)),
filteredFriends.value
.filter((x) => !x.accepted && x.id !== userCredentials.value?.user_id)
.slice()
.sort((a, b) => b.created.diff(a.created)),
)
const incomingRequests = computed(() =>
userFriends.value
.filter((x) => !x.accepted && x.id === userCredentials.value?.user_id)
.slice()
.sort((a, b) => b.created.diff(a.created)),
)
const loading = ref(true)
@@ -110,34 +113,7 @@ async function loadFriends(timeout = false) {
try {
const friendsList = await friends()
if (friendsList.length === 0) {
userFriends.value = []
} else {
const friendStatuses = await friend_statuses()
const users = await get_user_many(
friendsList.map((x) => (x.id === userCredentials.value.user_id ? x.friend_id : x.id)),
)
userFriends.value = friendsList.map((friend) => {
const user = users.find((x) => x.id === friend.id || x.id === friend.friend_id)
const status = friendStatuses.find(
(x) => x.user_id === friend.id || x.user_id === friend.friend_id,
)
return {
id: friend.id,
friend_id: friend.friend_id,
status: status?.profile_name,
last_updated: status && status.last_update ? dayjs(status.last_update) : null,
created: dayjs(friend.created),
avatar: user?.avatar_url,
username: user?.username,
online: !!status,
accepted: friend.accepted,
}
})
}
userFriends.value = await transformFriends(friendsList, userCredentials.value)
loading.value = false
} catch (e) {
console.error('Error loading friends', e)
@@ -152,6 +128,7 @@ watch(
() => {
if (userCredentials.value === undefined) {
userFriends.value = []
loading.value = false
} else if (userCredentials.value === null) {
userFriends.value = []
loading.value = false
@@ -166,49 +143,87 @@ const unlisten = await friend_listener(() => loadFriends())
onUnmounted(() => {
unlisten()
})
const messages = defineMessages({
addFriend: {
id: 'friends.action.add-friend',
defaultMessage: 'Add a friend',
},
addingAFriend: {
id: 'friends.add-friend.title',
defaultMessage: 'Adding a friend',
},
usernameTitle: {
id: 'friends.add-friend.username.title',
defaultMessage: "What's your friend's Modrinth username?",
},
usernameDescription: {
id: 'friends.add-friend.username.description',
defaultMessage: 'It may be different from their Minecraft username!',
},
usernamePlaceholder: {
id: 'friends.add-friend.username.placeholder',
defaultMessage: 'Enter Modrinth username...',
},
sendFriendRequest: {
id: 'friends.add-friend.submit',
defaultMessage: 'Send friend request',
},
viewFriendRequests: {
id: 'friends.action.view-friend-requests',
defaultMessage: '{count} friend {count, plural, one {request} other {requests}}',
},
searchFriends: {
id: 'friends.search-friends-placeholder',
defaultMessage: 'Search friends...',
},
friends: {
id: 'friends.heading',
defaultMessage: 'Friends',
},
pending: {
id: 'friends.heading.pending',
defaultMessage: 'Pending',
},
active: {
id: 'friends.heading.active',
defaultMessage: 'Active',
},
online: {
id: 'friends.heading.online',
defaultMessage: 'Online',
},
offline: {
id: 'friends.heading.offline',
defaultMessage: 'Offline',
},
noFriendsMatch: {
id: 'friends.no-friends-match',
defaultMessage: `No friends matching ''{query}''`,
},
signInToAddFriends: {
id: 'friends.sign-in-to-add-friends',
defaultMessage:
"<link>Sign in to a Modrinth account</link> to add friends and see what they're playing!",
},
addFriendsToShare: {
id: 'friends.add-friends-to-share',
defaultMessage: "<link>Add friends</link> to see what they're playing!",
},
})
</script>
<template>
<ModalWrapper ref="manageFriendsModal" header="Manage friends">
<p v-if="acceptedFriends.length === 0">Add friends to share what you're playing!</p>
<div v-else class="flex flex-col gap-4 min-w-[20rem]">
<input v-model="search" type="text" placeholder="Search friends..." class="w-full" />
<div
v-for="friend in acceptedFriends.filter(
(x) => !search || x.username.toLowerCase().includes(search),
)"
:key="friend.username"
class="flex gap-2 items-center"
>
<div class="relative">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<span
v-if="friend.online"
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div>{{ friend.username }}</div>
<div class="ml-auto">
<ButtonStyled>
<button @click="removeFriend(friend)">
<XIcon />
Remove
</button>
</ButtonStyled>
</div>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="friendInvitesModal" header="View friend requests">
<p v-if="pendingFriends.length === 0">You have no pending friend requests :C</p>
<div v-else class="flex flex-col gap-4">
<div v-for="friend in pendingFriends" :key="friend.username" class="flex gap-2">
<p v-if="incomingRequests.length === 0">You have no pending friend requests :C</p>
<div v-else class="flex flex-col gap-4 min-w-[40rem]">
<div v-for="friend in incomingRequests" :key="friend.username" class="flex gap-2">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<div class="flex flex-col gap-2">
<div class="grid grid-cols-[1fr_auto] w-full gap-4">
<div>
<p class="m-0">
<template v-if="friend.id === userCredentials.user_id">
<span class="font-bold">{{ friend.username }}</span> sent you a friend request
<template v-if="friend.id === userCredentials?.user_id">
<span class="text-contrast">{{ friend.username }}</span> sent you a friend request
</template>
<template v-else>
You sent <span class="font-bold">{{ friend.username }}</span> a friend request
@@ -219,7 +234,7 @@ onUnmounted(() => {
</p>
</div>
<div class="flex gap-2">
<template v-if="friend.id === userCredentials.user_id">
<template v-if="friend.id === userCredentials?.user_id">
<ButtonStyled color="brand">
<button @click="addFriend(friend)">
<UserPlusIcon />
@@ -246,78 +261,89 @@ onUnmounted(() => {
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="addFriendModal" header="Add a friend">
<div class="mb-4">
<h2 class="m-0 text-lg font-extrabold text-contrast">Username</h2>
<p class="m-0 mt-1 leading-tight">You can add friends with their Modrinth username.</p>
<input
v-model="username"
class="mt-2 w-full"
type="text"
placeholder="Enter username..."
@keyup.enter="addFriendFromModal"
/>
<ModalWrapper ref="addFriendModal" :header="formatMessage(messages.addingAFriend)">
<div class="min-w-[30rem]">
<h2 class="m-0 text-base font-medium text-primary">
{{ formatMessage(messages.usernameTitle) }}
</h2>
<p class="m-0 mt-1 text-sm text-secondary leading-tight">
{{ formatMessage(messages.usernameDescription) }}
</p>
<div class="flex items-center gap-2 mt-4">
<div class="iconified-input flex-1">
<UserIcon aria-hidden="true" />
<input
v-model="username"
type="text"
:placeholder="formatMessage(messages.usernamePlaceholder)"
@keyup.enter="addFriendFromModal"
/>
</div>
<ButtonStyled color="brand">
<button :disabled="username.length === 0" @click="addFriendFromModal">
<SendIcon />
{{ formatMessage(messages.sendFriendRequest) }}
</button>
</ButtonStyled>
</div>
</div>
<ButtonStyled color="brand">
<button :disabled="username.length === 0" @click="addFriendFromModal">
<UserPlusIcon />
Add friend
</ModalWrapper>
<div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 ml-2 mr-1">
<template v-if="sortedFriends.length > 0">
<ButtonStyled circular type="transparent">
<button
v-tooltip="formatMessage(messages.addFriend)"
:aria-label="formatMessage(messages.addFriend)"
@click="addFriendModal.show"
>
<UserPlusIcon />
</button>
</ButtonStyled>
<div class="iconified-input flex-1">
<input
v-model="search"
type="text"
class="friends-search-bar flex w-full"
:placeholder="formatMessage(messages.searchFriends)"
@keyup.esc="search = ''"
/>
<button
v-if="search"
v-tooltip="formatMessage(commonMessages.clearButton)"
class="r-btn flex items-center justify-center bg-transparent button-animation p-2 cursor-pointer appearance-none border-none"
@click="search = ''"
>
<XIcon />
</button>
</div>
</template>
<h3 v-else class="ml-2 w-full text-base text-primary font-medium m-0">
{{ formatMessage(messages.friends) }}
</h3>
<ButtonStyled v-if="incomingRequests.length > 0" circular type="transparent">
<button
v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
class="relative"
:aria-label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
@click="friendInvitesModal.show"
>
<MailIcon />
<span
v-if="incomingRequests.length > 0"
aria-hidden="true"
class="absolute bg-brand text-brand-inverted text-[8px] top-0.5 px-1 right-0.5 min-w-3 h-3 rounded-full flex items-center justify-center font-bold"
>
{{ incomingRequests.length }}
</span>
</button>
</ButtonStyled>
</ModalWrapper>
<div class="flex justify-between items-center">
<h3 class="text-lg m-0">Friends</h3>
<ButtonStyled v-if="userCredentials" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'add-friend',
action: () => addFriendModal.show(),
},
{
id: 'manage-friends',
action: () => manageFriendsModal.show(),
shown: acceptedFriends.length > 0,
},
{
id: 'view-requests',
action: () => friendInvitesModal.show(),
shown: pendingFriends.length > 0,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #add-friend>
<UserPlusIcon aria-hidden="true" />
Add friend
</template>
<template #manage-friends>
<SettingsIcon aria-hidden="true" />
Manage friends
<div
v-if="acceptedFriends.length > 0"
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
>
{{ acceptedFriends.length }}
</div>
</template>
<template #view-requests>
<MailIcon aria-hidden="true" />
View friend requests
<div
v-if="pendingFriends.length > 0"
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
>
{{ pendingFriends.length }}
</div>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<div class="flex flex-col gap-2 mt-2">
<div class="flex flex-col gap-3">
<h3 v-if="loading" class="ml-4 mr-1 text-base text-primary font-medium m-0">
{{ formatMessage(messages.friends) }}
</h3>
<template v-if="loading">
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse">
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse ml-4 mr-1">
<div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div>
<div class="flex flex-col w-full">
<div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div>
@@ -325,50 +351,77 @@ onUnmounted(() => {
</div>
</div>
</template>
<template v-else-if="acceptedFriends.length === 0">
<div class="text-sm">
<template v-else-if="sortedFriends.length === 0">
<div class="text-sm ml-4 mr-1">
<div v-if="!userCredentials">
<span class="text-link cursor-pointer" @click="signIn">Sign in</span> to add friends!
<IntlFormatted :message-id="messages.signInToAddFriends">
<template #link="{ children }">
<span class="font-semibold text-brand cursor-pointer" @click="signIn">
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</div>
<div v-else>
<span class="text-link cursor-pointer" @click="addFriendModal.show()">Add friends</span>
to share what you're playing!
<IntlFormatted :message-id="messages.addFriendsToShare">
<template #link="{ children }">
<span class="font-semibold text-brand cursor-pointer" @click="addFriendModal.show">
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</div>
</div>
</template>
<template v-else>
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
<template #remove-friend> <TrashIcon /> Remove friend </template>
</ContextMenu>
<div
v-for="friend in acceptedFriends.slice(0, 5)"
:key="friend.username"
class="flex gap-2 items-center"
:class="{ grayscale: !friend.online }"
@contextmenu.prevent.stop="
(event) =>
friendOptions.showMenu(event, friend, [
{
name: 'remove-friend',
color: 'danger',
},
])
"
>
<div class="relative">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<span
v-if="friend.online"
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span class="text-md m-0 font-medium" :class="{ 'text-secondary': !friend.online }">
{{ friend.username }}
</span>
<span v-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
</div>
<FriendsSection
v-if="activeFriends.length > 0"
:is-searching="!!search"
open-by-default
:friends="activeFriends"
:heading="formatMessage(messages.active)"
:remove-friend="removeFriend"
/>
<FriendsSection
v-if="onlineFriends.length > 0"
:is-searching="!!search"
open-by-default
:friends="onlineFriends"
:heading="formatMessage(messages.online)"
:remove-friend="removeFriend"
/>
<FriendsSection
v-if="offlineFriends.length > 0"
:is-searching="!!search"
:open-by-default="activeFriends.length + onlineFriends.length < 3"
:friends="offlineFriends"
:heading="formatMessage(messages.offline)"
:remove-friend="removeFriend"
/>
<FriendsSection
v-if="pendingFriends.length > 0"
:is-searching="!!search"
open-by-default
:friends="pendingFriends"
:heading="formatMessage(messages.pending)"
:remove-friend="removeFriend"
/>
<p v-if="filteredFriends.length === 0 && search" class="text-sm text-secondary my-1 mx-4">
{{ formatMessage(messages.noFriendsMatch, { query: search }) }}
</p>
</template>
</div>
</template>
<style scoped>
.friends-search-bar {
background: none;
border: 2px solid var(--color-button-bg) !important;
padding: 8px;
border-radius: 12px;
height: 36px;
}
.friends-search-bar::placeholder {
@apply text-sm font-normal;
}
</style>

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,
type Skin,
type SkinModel,
type SkinTextureUrl,
unequip_skin,
} from '@/helpers/skins.ts'
@@ -142,7 +143,7 @@ const currentSkin = ref<Skin | null>(null)
const shouldRestoreModal = ref(false)
const isSaving = ref(false)
const uploadedTextureUrl = ref<string | null>(null)
const uploadedTextureUrl = ref<SkinTextureUrl | null>(null)
const previewSkin = ref<string>('')
const variant = ref<SkinModel>('CLASSIC')
@@ -188,7 +189,7 @@ function getSortedCapeExcluding(excludeId: string): Cape | undefined {
async function loadPreviewSkin() {
if (uploadedTextureUrl.value) {
previewSkin.value = uploadedTextureUrl.value
previewSkin.value = uploadedTextureUrl.value.normalized
} else if (currentSkin.value) {
try {
previewSkin.value = await get_normalized_skin_texture(currentSkin.value)
@@ -253,11 +254,11 @@ async function show(e: MouseEvent, skin?: Skin) {
modal.value?.show(e)
}
async function showNew(e: MouseEvent, skinTextureUrl: string) {
async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
mode.value = 'new'
currentSkin.value = null
uploadedTextureUrl.value = skinTextureUrl
variant.value = await determineModelType(skinTextureUrl)
variant.value = await determineModelType(skinTextureUrl.original)
selectedCape.value = undefined
visibleCapeList.value = []
initVisibleCapeList()
@@ -267,7 +268,7 @@ async function showNew(e: MouseEvent, skinTextureUrl: string) {
modal.value?.show(e)
}
async function restoreWithNewTexture(skinTextureUrl: string) {
async function restoreWithNewTexture(skinTextureUrl: SkinTextureUrl) {
uploadedTextureUrl.value = skinTextureUrl
await loadPreviewSkin()
@@ -361,7 +362,7 @@ async function save() {
let textureUrl: string
if (uploadedTextureUrl.value) {
textureUrl = uploadedTextureUrl.value
textureUrl = uploadedTextureUrl.value.original
} else {
textureUrl = currentSkin.value!.texture
}

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'
export async function login() {
export type ModrinthCredentials = {
session: string
expires: string
user_id: string
active: boolean
}
export async function login(): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login')
}
export async function logout() {
export async function logout(): Promise<void> {
return await invoke('plugin:mr-auth|logout')
}
export async function get() {
export async function get(): Promise<ModrinthCredentials | null> {
return await invoke('plugin:mr-auth|get')
}
export async function cancelLogin() {
export async function cancelLogin(): Promise<void> {
return await invoke('plugin:mr-auth|cancel_modrinth_login')
}

View File

@@ -67,6 +67,8 @@ export type AppSettings = {
skipped_update: string | null
pending_update_toast_for_version: string | null
auto_download_updates: boolean | null
version: number
}
// Get full settings object

View File

@@ -22,6 +22,11 @@ export interface Skin {
is_equipped: boolean
}
export interface SkinTextureUrl {
original: string
normalized: string
}
export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[]
export const DEFAULT_MODELS: Record<string, SkinModel> = {

View File

@@ -65,6 +65,69 @@
"app.update.reload-to-update": {
"message": "Reload to install update"
},
"friends.action.add-friend": {
"message": "Add a friend"
},
"friends.action.view-friend-requests": {
"message": "{count} friend {count, plural, one {request} other {requests}}"
},
"friends.add-friend.submit": {
"message": "Send friend request"
},
"friends.add-friend.title": {
"message": "Adding a friend"
},
"friends.add-friend.username.description": {
"message": "It may be different from their Minecraft username!"
},
"friends.add-friend.username.placeholder": {
"message": "Enter Modrinth username..."
},
"friends.add-friend.username.title": {
"message": "What's your friend's Modrinth username?"
},
"friends.add-friends-to-share": {
"message": "<link>Add friends</link> to see what they're playing!"
},
"friends.friend.cancel-request": {
"message": "Cancel request"
},
"friends.friend.remove-friend": {
"message": "Remove friend"
},
"friends.friend.request-sent": {
"message": "Friend request sent"
},
"friends.friend.view-profile": {
"message": "View profile"
},
"friends.heading": {
"message": "Friends"
},
"friends.heading.active": {
"message": "Active"
},
"friends.heading.offline": {
"message": "Offline"
},
"friends.heading.online": {
"message": "Online"
},
"friends.heading.pending": {
"message": "Pending"
},
"friends.no-friends-match": {
"message": "No friends matching ''{query}''"
},
"friends.search-friends-placeholder": {
"message": "Search friends..."
},
"friends.section.heading": {
"message": "{title} - {count}"
},
"friends.sign-in-to-add-friends": {
"message": "<link>Sign in to a Modrinth account</link> to add friends and see what they're playing!"
},
"instance.add-server.add-and-play": {
"message": "Add and play"
},

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 { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import { get as getSettings } from '@/helpers/settings.ts'
import type { Cape, Skin } from '@/helpers/skins.ts'
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
import {
equip_skin,
filterDefaultSkins,
@@ -245,16 +245,18 @@ function openUploadSkinModal(e: MouseEvent) {
function onSkinFileUploaded(buffer: ArrayBuffer) {
const fakeEvent = new MouseEvent('click')
normalize_skin_texture(`data:image/png;base64,` + arrayBufferToBase64(buffer)).then(
(skinTextureNormalized: Uint8Array) => {
const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized)
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
editSkinModal.value.restoreWithNewTexture(skinTexUrl)
} else {
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
}
},
)
const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer)
normalize_skin_texture(originalSkinTexUrl).then((skinTextureNormalized: Uint8Array) => {
const skinTexUrl: SkinTextureUrl = {
original: originalSkinTexUrl,
normalized: `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized),
}
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
editSkinModal.value.restoreWithNewTexture(skinTexUrl)
} else {
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
}
})
}
function onUploadCanceled() {

View File

@@ -139,9 +139,9 @@
<template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EditIcon /> Edit </template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #open_folder> <ClipboardCopyIcon /> Open folder </template>
<template #open_folder> <FolderOpenIcon /> Open folder </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
<template #open_link> <ClipboardCopyIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_names><EditIcon />Copy names</template>
<template #copy_slugs><HashIcon />Copy slugs</template>
<template #copy_links><GlobeIcon />Copy links</template>

View File

@@ -92,7 +92,7 @@ import {
XIcon,
} from '@modrinth/assets'
import { Button, Card } from '@modrinth/ui'
import { ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
// import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { trackEvent } from '@/helpers/analytics'
@@ -151,17 +151,26 @@ const expandImage = (item, index) => {
function keyListener(e) {
if (expandedGalleryItem.value) {
e.preventDefault()
if (e.key === 'Escape') {
e.preventDefault()
hideImage()
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
previousImage()
} else if (e.key === 'ArrowRight') {
e.preventDefault()
nextImage()
}
}
}
document.addEventListener('keypress', keyListener)
onMounted(() => {
document.addEventListener('keydown', keyListener)
})
onUnmounted(() => {
document.removeEventListener('keydown', keyListener)
})
</script>
<style scoped lang="scss">

View File

@@ -148,9 +148,15 @@ fn main() {
} else {
"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(),
)
)
.setup(|app| {
#[cfg(target_os = "macos")]
{

View File

@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1
FROM rust:1.89.0 AS build
FROM rust:1.90.0 AS build
WORKDIR /usr/src/daedalus
COPY . .
@@ -14,7 +14,7 @@ RUN --mount=type=cache,target=/usr/src/daedalus/target \
mkdir /daedalus \
&& cp /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
FROM debian:bookworm-slim
FROM debian:trixie-slim
LABEL org.opencontainers.image.source=https://github.com/modrinth/code
LABEL org.opencontainers.image.title=daedalus

View File

@@ -2762,10 +2762,7 @@
},
{
"_comment": "Replace glfw from 3.3.1 with version from 3.3.2 to prevent stack smashing",
"match": [
"org.lwjgl:lwjgl-glfw-natives-linux:3.3.1",
"org.lwjgl:lwjgl-glfw:3.3.1:natives-linux"
],
"match": ["org.lwjgl:lwjgl-glfw-natives-linux:3.3.1"],
"override": {
"downloads": {
"artifact": {
@@ -2776,5 +2773,115 @@
},
"name": "org.lwjgl:lwjgl-glfw-natives-linux:3.3.2-lwjgl.1"
}
},
{
"_comment": "Use newer JNA on macOS to prevent crashes due to faulty assertion",
"match": [
"net.java.dev.jna:jna:5.6.0",
"net.java.dev.jna:jna:5.8.0",
"net.java.dev.jna:jna:5.9.0",
"net.java.dev.jna:jna:5.10.0",
"net.java.dev.jna:jna:5.12.1"
],
"override": {
"rules": [
{
"action": "allow"
},
{
"action": "disallow",
"os": {
"name": "osx"
}
},
{
"action": "disallow",
"os": {
"name": "osx-arm64"
}
}
]
},
"additionalLibraries": [
{
"downloads": {
"artifact": {
"sha1": "1200e7ebeedbe0d10062093f32925a912020e747",
"size": 1879325,
"url": "https://libraries.minecraft.net/net/java/dev/jna/jna/5.13.0/jna-5.13.0.jar"
}
},
"name": "net.java.dev.jna:jna:5.13.0",
"rules": [
{
"action": "allow",
"os": {
"name": "osx"
}
},
{
"action": "allow",
"os": {
"name": "osx-arm64"
}
}
]
}
]
},
{
"_comment": "Use newer JNA on macOS to prevent crashes due to faulty assertion",
"match": [
"net.java.dev.jna:jna-platform:5.6.0",
"net.java.dev.jna:jna-platform:5.8.0",
"net.java.dev.jna:jna-platform:5.9.0",
"net.java.dev.jna:jna-platform:5.10.0",
"net.java.dev.jna:jna-platform:5.12.1"
],
"override": {
"rules": [
{
"action": "allow"
},
{
"action": "disallow",
"os": {
"name": "osx"
}
},
{
"action": "disallow",
"os": {
"name": "osx-arm64"
}
}
]
},
"additionalLibraries": [
{
"downloads": {
"artifact": {
"sha1": "88e9a306715e9379f3122415ef4ae759a352640d",
"size": 1363209,
"url": "https://libraries.minecraft.net/net/java/dev/jna/jna-platform/5.13.0/jna-platform-5.13.0.jar"
}
},
"name": "net.java.dev.jna:jna-platform:5.13.0",
"rules": [
{
"action": "allow",
"os": {
"name": "osx"
}
},
{
"action": "allow",
"os": {
"name": "osx-arm64"
}
}
]
}
]
}
]

View File

@@ -186,6 +186,8 @@ pub struct LibraryPatch {
}
fn fetch_library_patches() -> Result<Vec<LibraryPatch>, Error> {
// The file below is a copy of https://github.com/PrismLauncher/meta/blob/main/meta/common/mojang-library-patches.json.
// That file belongs to a repository licensed under the Microsoft Public License (Ms-PL)
let patches = include_bytes!("../library-patches.json");
Ok(serde_json::from_slice(patches)?)
}

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.
[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:

View File

@@ -2,9 +2,8 @@
::backdrop,
:root[data-theme='light'],
[data-theme='light'] ::backdrop {
--sl-font-system:
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--sl-font-system: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--sl-color-white: var(--color-contrast); /* “white” */
--sl-color-gray-1: var(--color-base);

View File

@@ -1,20 +1,23 @@
<template>
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
<nuxt-link
:to="flags.enableMedalPromotion ? '/servers?plan&ref=medal' : '/servers'"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
<div class="wrapper relative mb-3 flex w-full justify-center rounded-2xl">
<AutoLink
:to="currentAd.link"
:aria-label="currentAd.description"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit] bg-bg-raised"
>
<img
:src="`https://cdn-raw.modrinth.com/${flags.enableMedalPromotion ? 'medal-modrinth-servers' : 'modrinth-servers-placeholder'}-light.webp`"
alt="Host your next server with Modrinth Servers"
:src="currentAd.light"
aria-hidden="true"
:alt="currentAd.description"
class="light-image hidden rounded-[inherit]"
/>
<img
:src="`https://cdn-raw.modrinth.com/${flags.enableMedalPromotion ? 'medal-modrinth-servers' : 'modrinth-servers-placeholder'}-dark.webp`"
alt="Host your next server with Modrinth Servers"
:src="currentAd.dark"
aria-hidden="true"
:alt="currentAd.description"
class="dark-image rounded-[inherit]"
/>
</nuxt-link>
</AutoLink>
<div
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
>
@@ -23,6 +26,8 @@
</div>
</template>
<script setup>
import { AutoLink } from '@modrinth/ui'
const flags = useFeatureFlags()
useHead({
@@ -55,6 +60,25 @@ useHead({
],
})
const AD_PRESETS = {
medal: {
light: 'https://cdn-raw.modrinth.com/medal-modrinth-servers-light-new.webp',
dark: 'https://cdn-raw.modrinth.com/medal-modrinth-servers-dark-new.webp',
description: 'Host your next server with Modrinth Servers',
link: '/servers?plan&ref=medal',
},
'modrinth-servers': {
light: 'https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp',
dark: 'https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp',
description: 'Host your next server with Modrinth Servers',
link: '/servers',
},
}
const currentAd = computed(() =>
flags.value.enableMedalPromotion ? AD_PRESETS.medal : AD_PRESETS['modrinth-servers'],
)
onMounted(() => {
window.tude = window.tude || { cmd: [] }
window.Raven = window.Raven || { cmd: [] }
@@ -137,10 +161,14 @@ iframe[id^='google_ads_iframe'] {
}
@media (max-width: 1024px) {
.ad-parent {
.wrapper {
display: none;
}
}
.wrapper > * {
box-shadow: var(--shadow-card);
}
</style>
<style lang="scss" scoped>

View File

@@ -8,6 +8,11 @@
{{ analytics.error.value }}
</div>
</div>
<div v-else-if="!isInitialized || analytics.loading.value" class="universal-card">
<h2>
<span class="label__title">Loading analytics...</span>
</h2>
</div>
<div v-else class="graphs">
<div class="graphs__vertical-bar">
<client-only>
@@ -419,6 +424,7 @@ const isUsingProjectColors = computed({
const startDate = ref(dayjs().startOf('day'))
const endDate = ref(dayjs().endOf('day'))
const timeResolution = ref(30)
const isInitialized = ref(false)
onBeforeMount(() => {
// Load cached data and range from localStorage - cache.
@@ -449,6 +455,8 @@ onMounted(() => {
startDate.value = ranges.startDate
endDate.value = ranges.endDate
timeResolution.value = selectedRange.value.timeResolution
isInitialized.value = true
})
const internalRange: Ref<RangeObject> = ref(null as unknown as RangeObject)
@@ -482,6 +490,7 @@ const analytics = useFetchAllAnalytics(
startDate,
endDate,
timeResolution,
isInitialized,
)
const formattedCategorySubtitle = computed(() => {

View File

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

View File

@@ -13,14 +13,23 @@ import {
TrashIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from '@modrinth/ui'
import {
ButtonStyled,
commonMessages,
injectNotificationManager,
OverflowMenu,
ProgressBar,
} from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const flags = useFeatureFlags()
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const emit = defineEmits<{
(e: 'download' | 'rename' | 'restore' | 'lock' | 'retry'): void
@@ -33,11 +42,13 @@ const props = withDefaults(
preview?: boolean
kyrosUrl?: string
jwt?: string
server?: ModrinthServer
}>(),
{
preview: false,
kyrosUrl: undefined,
jwt: undefined,
server: undefined,
},
)
@@ -124,7 +135,48 @@ const messages = defineMessages({
id: 'servers.backups.item.retry',
defaultMessage: 'Retry',
},
downloadingBackup: {
id: 'servers.backups.item.downloading-backup',
defaultMessage: 'Downloading backup...',
},
downloading: {
id: 'servers.backups.item.downloading',
defaultMessage: 'Downloading',
},
})
const downloadingState = ref<{ progress: number; state: string } | undefined>(undefined)
const downloading = computed(() => downloadingState.value)
const handleDownload = async () => {
if (!props.server?.backups || downloading.value) {
return
}
downloadingState.value = { progress: 0, state: 'ongoing' }
try {
const download = props.server.backups.downloadBackup(props.backup.id, props.backup.name)
download.onProgress((p) => {
downloadingState.value = { progress: p.progress, state: 'ongoing' }
})
await download.promise
emit('download')
} catch (error) {
console.error('Failed to download backup:', error)
addNotification({
type: 'error',
title: 'Download failed',
text: error instanceof Error ? error.message : 'Failed to download backup',
})
} finally {
downloadingState.value = undefined
}
}
</script>
<template>
<div
@@ -192,6 +244,15 @@ const messages = defineMessages({
class="max-w-full"
/>
</div>
<div v-else-if="downloading" class="col-span-2 flex flex-col gap-3 text-blue">
{{ formatMessage(messages.downloadingBackup) }}
<ProgressBar
:progress="downloading.progress >= 0 ? downloading.progress : 0"
color="blue"
:waiting="downloading.progress <= 0"
class="max-w-full"
/>
</div>
<template v-else>
<div class="col-span-2">
{{ dayjs(backup.created_at).format('MMMM D, YYYY [at] h:mm A') }}
@@ -223,34 +284,32 @@ const messages = defineMessages({
</button>
</ButtonStyled>
<template v-else>
<ButtonStyled>
<a
:class="{
disabled: !kyrosUrl || !jwt,
}"
:href="`https://${kyrosUrl}/modrinth/v0/backups/${backup.id}/download?auth=${jwt}`"
@click="() => emit('download')"
>
<ButtonStyled v-show="!downloading">
<button :disabled="!server?.backups" @click="handleDownload">
<DownloadIcon />
{{ formatMessage(commonMessages.downloadButton) }}
</a>
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{ id: 'rename', action: () => emit('rename') },
{
id: 'rename',
action: () => emit('rename'),
disabled: !!restoring || !!downloading,
},
{
id: 'restore',
action: () => emit('restore'),
disabled: !!restoring,
disabled: !!restoring || !!downloading,
},
{ id: 'lock', action: () => emit('lock') },
{ id: 'lock', action: () => emit('lock'), disabled: !!restoring || !!downloading },
{ divider: true },
{
id: 'delete',
color: 'red',
action: () => emit('delete'),
disabled: !!restoring,
disabled: !!restoring || !!downloading,
},
]"
>

View File

@@ -68,7 +68,11 @@
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button :disabled="!canTakeAction" @click="handlePrimaryAction">
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="!canTakeAction"
@click="handlePrimaryAction"
>
<div v-if="isTransitionState" class="grid place-content-center">
<LoadingIcon />
</div>
@@ -122,12 +126,15 @@ import { useStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
import LoadingIcon from './icons/LoadingIcon.vue'
import PanelSpinner from './PanelSpinner.vue'
import ServerInfoLabels from './ServerInfoLabels.vue'
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
const flags = useFeatureFlags()
const { formatMessage } = useVIntl()
interface PowerAction {
action: ServerPowerAction
@@ -142,6 +149,7 @@ const props = defineProps<{
serverName?: string
serverData: object
uptimeSeconds: number
backupInProgress?: BackupInProgressReason
}>()
const emit = defineEmits<{
@@ -163,7 +171,11 @@ const dontAskAgain = ref(false)
const startingDelay = ref(false)
const canTakeAction = computed(
() => !props.isActioning && !startingDelay.value && !isTransitionState.value,
() =>
!props.isActioning &&
!startingDelay.value &&
!isTransitionState.value &&
!props.backupInProgress,
)
const isRunning = computed(() => serverState.value === 'running')
const isTransitionState = computed(() =>

View File

@@ -158,11 +158,20 @@ const currentPlanFromSubscription = computed<ServerPlan | undefined>(() => {
: undefined
})
const currentInterval = computed(() => {
const interval = subscription.value?.interval
if (interval === 'monthly' || interval === 'quarterly') {
return interval
}
return 'monthly'
})
async function initiatePayment(body: any): Promise<any> {
if (subscription.value) {
const transformedBody = {
interval: body.charge?.interval,
payment_method: body.id,
payment_method: body.type === 'confirmation_token' ? body.token : body.id,
product: body.charge?.product_id,
region: body.metadata?.server_region,
}
@@ -247,7 +256,7 @@ async function open(id?: string) {
subscription.value = null
}
purchaseModal.value?.show('quarterly')
purchaseModal.value?.show(currentInterval.value)
}
defineExpose({

View File

@@ -11,12 +11,35 @@ export class BackupsModule extends ServerModule {
}
async create(backupName: string): Promise<string> {
const response = await useServersFetch<{ id: string }>(`servers/${this.serverId}/backups`, {
method: 'POST',
body: { name: backupName },
})
await this.fetch() // Refresh this module
return response.id
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substring(7)}`
const tempBackup: Backup = {
id: tempId,
name: backupName,
created_at: new Date().toISOString(),
locked: false,
automated: false,
interrupted: false,
ongoing: true,
task: { create: { progress: 0, state: 'ongoing' } },
}
this.data.push(tempBackup)
try {
const response = await useServersFetch<{ id: string }>(`servers/${this.serverId}/backups`, {
method: 'POST',
body: { name: backupName },
})
const backup = this.data.find((b) => b.id === tempId)
if (backup) {
backup.id = response.id
}
return response.id
} catch (error) {
this.data = this.data.filter((b) => b.id !== tempId)
throw error
}
}
async rename(backupId: string, newName: string): Promise<void> {
@@ -24,35 +47,47 @@ export class BackupsModule extends ServerModule {
method: 'POST',
body: { name: newName },
})
await this.fetch() // Refresh this module
await this.fetch()
}
async delete(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}`, {
method: 'DELETE',
})
await this.fetch() // Refresh this module
await this.fetch()
}
async restore(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/restore`, {
method: 'POST',
})
await this.fetch() // Refresh this module
const backup = this.data.find((b) => b.id === backupId)
if (backup) {
if (!backup.task) backup.task = {}
backup.task.restore = { progress: 0, state: 'ongoing' }
}
try {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/restore`, {
method: 'POST',
})
} catch (error) {
if (backup?.task?.restore) {
delete backup.task.restore
}
throw error
}
}
async lock(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/lock`, {
method: 'POST',
})
await this.fetch() // Refresh this module
await this.fetch()
}
async unlock(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/unlock`, {
method: 'POST',
})
await this.fetch() // Refresh this module
await this.fetch()
}
async retry(backupId: string): Promise<void> {
@@ -71,4 +106,87 @@ export class BackupsModule extends ServerModule {
async getAutoBackup(): Promise<AutoBackupSettings> {
return await useServersFetch(`servers/${this.serverId}/autobackup`)
}
downloadBackup(
backupId: string,
backupName: string,
): {
promise: Promise<void>
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => void
cancel: () => void
} {
const progressSubject = new EventTarget()
const abortController = new AbortController()
const downloadPromise = new Promise<void>((resolve, reject) => {
const auth = this.server.general?.node
if (!auth?.instance || !auth?.token) {
reject(new Error('Missing authentication credentials'))
return
}
const xhr = new XMLHttpRequest()
xhr.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = e.loaded / e.total
progressSubject.dispatchEvent(
new CustomEvent('progress', {
detail: { loaded: e.loaded, total: e.total, progress },
}),
)
} else {
// progress = -1 to indicate indeterminate size
progressSubject.dispatchEvent(
new CustomEvent('progress', {
detail: { loaded: e.loaded, total: 0, progress: -1 },
}),
)
}
})
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const blob = xhr.response
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${backupName}.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
resolve()
} catch (error) {
reject(error)
}
} else {
reject(new Error(`Download failed with status ${xhr.status}`))
}
}
xhr.onerror = () => reject(new Error('Download failed'))
xhr.onabort = () => reject(new Error('Download cancelled'))
xhr.open(
'GET',
`https://${auth.instance}/modrinth/v0/backups/${backupId}/download?auth=${auth.token}`,
)
xhr.responseType = 'blob'
xhr.send()
abortController.signal.addEventListener('abort', () => xhr.abort())
})
return {
promise: downloadPromise,
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => {
progressSubject.addEventListener('progress', ((e: CustomEvent) => {
cb(e.detail)
}) as EventListener)
},
cancel: () => abortController.abort(),
}
}
}

View File

@@ -6,7 +6,7 @@ export interface ServersFetchOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
contentType?: string
body?: Record<string, any>
version?: number
version?: number | 'internal'
override?: {
url?: string
token?: string
@@ -30,7 +30,7 @@ export async function useServersFetch<T>(
'[Modrinth Servers] Cannot fetch without auth',
10000,
)
throw new ModrinthServerError('Missing auth token', 401, error, module)
throw new ModrinthServerError('Missing auth token', 401, error, module, undefined, undefined)
}
const {
@@ -52,7 +52,14 @@ export async function useServersFetch<T>(
'[Modrinth Servers] Circuit breaker open - too many recent failures',
503,
)
throw new ModrinthServerError('Service temporarily unavailable', 503, error, module)
throw new ModrinthServerError(
'Service temporarily unavailable',
503,
error,
module,
undefined,
undefined,
)
}
if (now - lastFailureTime.value > 30000) {
@@ -69,7 +76,14 @@ export async function useServersFetch<T>(
'[Modrinth Servers] Cannot fetch without base url. Make sure to set a PYRO_BASE_URL in environment variables',
10001,
)
throw new ModrinthServerError('Configuration error: Missing PYRO_BASE_URL', 500, error, module)
throw new ModrinthServerError(
'Configuration error: Missing PYRO_BASE_URL',
500,
error,
module,
undefined,
undefined,
)
}
const versionString = `v${version}`
@@ -82,7 +96,9 @@ export async function useServersFetch<T>(
? `https://${newOverrideUrl}/${path.replace(/^\//, '')}`
: version === 0
? `${base}/modrinth/v${version}/${path.replace(/^\//, '')}`
: `${base}/v${version}/${path.replace(/^\//, '')}`
: version === 'internal'
? `${base}/_internal/${path.replace(/^\//, '')}`
: `${base}/v${version}/${path.replace(/^\//, '')}`
const headers: Record<string, string> = {
'User-Agent': 'Modrinth/1.0 (https://modrinth.com)',
@@ -177,6 +193,7 @@ export async function useServersFetch<T>(
fetchError,
module,
v1Error,
error.data,
)
}
@@ -198,6 +215,8 @@ export async function useServersFetch<T>(
undefined,
fetchError,
module,
undefined,
undefined,
)
}
}
@@ -210,7 +229,14 @@ export async function useServersFetch<T>(
statusCode,
lastError,
)
throw new ModrinthServerError('Maximum retry attempts reached', statusCode, pyroError, module)
throw new ModrinthServerError(
'Maximum retry attempts reached',
statusCode,
pyroError,
module,
undefined,
lastError.data,
)
}
const fetchError = new ModrinthServersFetchError(
@@ -218,5 +244,12 @@ export async function useServersFetch<T>(
undefined,
lastError || undefined,
)
throw new ModrinthServerError('Maximum retry attempts reached', undefined, fetchError, module)
throw new ModrinthServerError(
'Maximum retry attempts reached',
undefined,
fetchError,
module,
undefined,
undefined,
)
}

View File

@@ -82,6 +82,23 @@
</ButtonStyled>
</template>
</PagewideBanner>
<PagewideBanner v-if="showTinMismatchBanner" variant="error">
<template #title>
<span>{{ formatMessage(tinMismatchBannerMessages.title) }}</span>
</template>
<template #description>
<span>{{ formatMessage(tinMismatchBannerMessages.description) }}</span>
</template>
<template #actions>
<div class="flex w-fit flex-row">
<ButtonStyled color="red">
<nuxt-link to="https://support.modrinth.com" target="_blank" rel="noopener">
<MessageIcon /> {{ formatMessage(tinMismatchBannerMessages.action) }}
</nuxt-link>
</ButtonStyled>
</div>
</template>
</PagewideBanner>
<PagewideBanner v-if="showTaxComplianceBanner" variant="warning">
<template #title>
<span>{{ formatMessage(taxBannerMessages.title) }}</span>
@@ -444,6 +461,12 @@
link: '/admin/servers/notices',
shown: isAdmin(auth.user),
},
{
id: 'servers-nodes',
color: 'primary',
link: '/admin/servers/nodes',
shown: isAdmin(auth.user),
},
]"
>
<ModrinthIcon aria-hidden="true" />
@@ -463,6 +486,7 @@
<template #servers-notices>
<IssuesIcon aria-hidden="true" /> {{ formatMessage(messages.manageServerNotices) }}
</template>
<template #servers-nodes> <ServerIcon aria-hidden="true" /> Server Nodes </template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled type="transparent">
@@ -851,6 +875,7 @@ import {
LogInIcon,
LogOutIcon,
MastodonIcon,
MessageIcon,
ModrinthIcon,
MoonIcon,
OrganizationIcon,
@@ -918,7 +943,15 @@ const showTaxComplianceBanner = computed(() => {
const thresholdMet = (bal.withdrawn_ytd ?? 0) >= 600
const status = bal.form_completion_status ?? 'unknown'
const isComplete = status === 'complete'
return !!auth.value.user && thresholdMet && !isComplete
const isTinMismatch = status === 'tin-mismatch'
return !!auth.value.user && thresholdMet && !isComplete && !isTinMismatch
})
const showTinMismatchBanner = computed(() => {
const bal = payoutBalance.value
if (!bal) return false
const status = bal.form_completion_status ?? 'unknown'
return !!auth.value.user && status === 'tin-mismatch'
})
const taxBannerMessages = defineMessages({
@@ -929,7 +962,7 @@ const taxBannerMessages = defineMessages({
description: {
id: 'layout.banner.tax.description',
defaultMessage:
'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: {
id: 'layout.banner.tax.action',
@@ -937,6 +970,22 @@ const taxBannerMessages = defineMessages({
},
})
const tinMismatchBannerMessages = defineMessages({
title: {
id: 'layout.banner.tin-mismatch.title',
defaultMessage: 'Tax form failed',
},
description: {
id: 'layout.banner.tin-mismatch.description',
defaultMessage:
"Your withdrawals are temporarily locked because your TIN or SSN didn't match IRS records. Please contact support to reset and resubmit your tax form.",
},
action: {
id: 'layout.banner.tin-mismatch.action',
defaultMessage: 'Contact support',
},
})
const taxFormModalRef = ref(null)
function openTaxForm(e) {
if (taxFormModalRef.value && taxFormModalRef.value.startTaxForm) {
@@ -1732,6 +1781,7 @@ const footerLinks = [
@media screen and (min-width: 354px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 674px) {
grid-template-columns: repeat(3, 1fr);
}
@@ -1926,10 +1976,12 @@ const footerLinks = [
width: 25rem;
height: 25rem;
}
.animation-ring-2 {
width: 50rem;
height: 50rem;
}
.animation-ring-3 {
width: 100rem;
height: 100rem;
@@ -1958,15 +2010,19 @@ const footerLinks = [
0% {
rotate: 0deg;
}
25% {
rotate: calc(1deg * (var(--_r-count) - 20));
}
50% {
rotate: 0deg;
}
75% {
rotate: calc(-1deg * (var(--_r-count) - 20));
}
100% {
rotate: 0deg;
}
@@ -1976,15 +2032,19 @@ const footerLinks = [
0% {
translate: 0;
}
25% {
translate: calc(2px * (var(--_r-count) - 20));
}
50% {
translate: 0;
}
75% {
translate: calc(-2px * (var(--_r-count) - 20));
}
100% {
translate: 0;
}
@@ -1994,15 +2054,19 @@ const footerLinks = [
0% {
transform: translateY(0);
}
25% {
transform: translateY(calc(2px * (var(--_r-count) - 20)));
}
50% {
transform: translateY(0);
}
75% {
transform: translateY(calc(-2px * (var(--_r-count) - 20)));
}
100% {
transform: translateY(0);
}

View File

@@ -924,11 +924,20 @@
"message": "Complete tax form"
},
"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": {
"message": "Tax form required"
},
"layout.banner.tin-mismatch.action": {
"message": "Contact support"
},
"layout.banner.tin-mismatch.description": {
"message": "Your withdrawals are temporarily locked because your TIN or SSN didn't match IRS records. Please contact support to reset and resubmit your tax form."
},
"layout.banner.tin-mismatch.title": {
"message": "Tax form failed"
},
"layout.banner.verify-email.action": {
"message": "Re-send verification email"
},
@@ -1103,6 +1112,12 @@
"moderation.technical.search.placeholder": {
"message": "Search tech reviews..."
},
"profile.bio.fallback.creator": {
"message": "A Modrinth creator."
},
"profile.bio.fallback.user": {
"message": "A Modrinth user."
},
"profile.button.billing": {
"message": "Manage user billing"
},
@@ -1115,18 +1130,48 @@
"profile.button.manage-projects": {
"message": "Manage projects"
},
"profile.details.label.auth-providers": {
"message": "Auth providers"
},
"profile.details.label.email": {
"message": "Email"
},
"profile.details.label.has-password": {
"message": "Has password"
},
"profile.details.label.has-totp": {
"message": "Has TOTP"
},
"profile.details.label.payment-methods": {
"message": "Payment methods"
},
"profile.details.tooltip.email-not-verified": {
"message": "Email not verified"
},
"profile.details.tooltip.email-verified": {
"message": "Email verified"
},
"profile.error.not-found": {
"message": "User not found"
},
"profile.joined-at": {
"message": "Joined <date>{ago}</date>"
},
"profile.label.badges": {
"message": "Badges"
},
"profile.label.collection": {
"message": "Collection"
},
"profile.label.details": {
"message": "Details"
},
"profile.label.downloads": {
"message": "{count} {count, plural, one {download} other {downloads}}"
},
"profile.label.joined": {
"message": "Joined"
},
"profile.label.no": {
"message": "No"
},
"profile.label.no-collections": {
"message": "This user has no collections!"
},
@@ -1142,18 +1187,21 @@
"profile.label.organizations": {
"message": "Organizations"
},
"profile.label.projects": {
"message": "{count} {count, plural, one {project} other {projects}}"
},
"profile.label.saving": {
"message": "Saving..."
},
"profile.label.yes": {
"message": "Yes"
},
"profile.meta.description": {
"message": "Download {username}'s projects on Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Download {username}'s projects on Modrinth"
},
"profile.stats.downloads": {
"message": "{count, plural, one {<stat>{count}</stat> project download} other {<stat>{count}</stat> project downloads}}"
},
"profile.stats.projects": {
"message": "{count, plural, one {<stat>{count}</stat> project} other {<stat>{count}</stat> projects}}"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> project follower} other {<stat>{count}</stat> project followers}}"
},
@@ -1886,6 +1934,12 @@
"servers.backups.item.creating-backup": {
"message": "Creating backup..."
},
"servers.backups.item.downloading": {
"message": "Downloading"
},
"servers.backups.item.downloading-backup": {
"message": "Downloading backup..."
},
"servers.backups.item.failed-to-create-backup": {
"message": "Failed to create backup"
},

View File

@@ -97,6 +97,41 @@
</div>
</div>
</NewModal>
<NewModal ref="creditModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">Credit subscription</span>
</template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="days" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">Days to credit</span>
<span>Enter the number of days to add to the next due date.</span>
</label>
<input id="days" v-model.number="creditDays" type="number" min="1" autocomplete="off" />
</div>
<div class="flex flex-col gap-2">
<label for="sendEmail" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">Send email to user</span>
<span>Notify the user about the credited days.</span>
</label>
<Toggle id="sendEmail" v-model="creditSendEmail" />
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button :disabled="crediting" @click="applyCredit">
<CheckIcon aria-hidden="true" />
Apply credit
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="creditModal.hide()">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="page experimental-styles-within">
<div
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
@@ -140,6 +175,7 @@
</div>
</div>
<div v-if="subscription.metadata?.id" class="flex flex-col items-end gap-2">
<CopyCode :text="subscription.metadata.id" />
<ButtonStyled
v-if="
subscription.metadata?.type === 'pyro' || subscription.metadata?.type === 'medal'
@@ -153,7 +189,12 @@
<ServerIcon /> Server panel <ExternalIcon class="h-4 w-4" />
</nuxt-link>
</ButtonStyled>
<CopyCode :text="subscription.metadata.id" />
<ButtonStyled>
<button @click="showCreditModal(subscription)">
<CurrencyIcon />
Credit
</button>
</ButtonStyled>
</div>
</div>
<div class="flex flex-col gap-2">
@@ -292,6 +333,7 @@ import {
useRelativeTime,
} from '@modrinth/ui'
import { formatCategory, formatPrice } from '@modrinth/utils'
import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
import dayjs from 'dayjs'
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
@@ -370,6 +412,11 @@ const modifying = ref(false)
const modifyModal = ref()
const cancel = ref(false)
const crediting = ref(false)
const creditModal = ref()
const creditDays = ref(7)
const creditSendEmail = ref(true)
function showRefundModal(charge) {
selectedCharge.value = charge
refundType.value = 'full'
@@ -385,6 +432,44 @@ function showModifyModal(charge, subscription) {
modifyModal.value.show()
}
function showCreditModal(subscription) {
selectedSubscription.value = subscription
creditDays.value = 1
creditSendEmail.value = true
creditModal.value.show()
}
async function applyCredit() {
crediting.value = true
try {
const daysParsed = Math.max(1, Math.floor(Number(creditDays.value) || 1))
await useBaseFetch('billing/credit', {
method: 'POST',
body: JSON.stringify({
subscription_ids: [selectedSubscription.value.id],
days: daysParsed,
send_email: creditSendEmail.value,
message: DEFAULT_CREDIT_EMAIL_MESSAGE,
}),
internal: true,
})
addNotification({
title: 'Credit applied',
text: 'The subscription due date has been updated.',
type: 'success',
})
await refreshCharges()
creditModal.value.hide()
} catch (err) {
addNotification({
title: 'Error applying credit',
text: err.data?.description ?? String(err),
type: 'error',
})
}
crediting.value = false
}
async function refundCharge() {
refunding.value = true
try {

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">
<p>{{ formatMessage(postVerificationMessages.description) }}</p>
<NuxtLink v-if="auth.user" class="btn" link="/settings/account">
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
</NuxtLink>
<NuxtLink v-else to="/auth/sign-in" class="btn btn-primary continue-btn centered-btn">
{{ formatMessage(messages.signIn) }} <RightArrowIcon />
</NuxtLink>
<ButtonStyled v-if="auth.user">
<NuxtLink to="/settings/account">
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
</NuxtLink>
</ButtonStyled>
<ButtonStyled v-else>
<NuxtLink to="/auth/sign-in">
{{ formatMessage(messages.signIn) }}
<RightArrowIcon />
</NuxtLink>
</ButtonStyled>
</section>
</template>
@@ -40,24 +45,26 @@
</template>
</p>
<button
v-if="auth.user"
class="btn btn-primary continue-btn"
@click="handleResendEmailVerification"
>
{{ formatMessage(failedVerificationMessages.action) }} <RightArrowIcon />
</button>
<ButtonStyled v-if="auth.user" color="brand">
<button @click="handleResendEmailVerification">
{{ formatMessage(failedVerificationMessages.action) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<NuxtLink v-else to="/auth/sign-in" class="btn btn-primary continue-btn centered-btn">
{{ formatMessage(messages.signIn) }} <RightArrowIcon />
</NuxtLink>
<ButtonStyled v-else color="brand">
<NuxtLink to="/auth/sign-in">
{{ formatMessage(messages.signIn) }}
<RightArrowIcon />
</NuxtLink>
</ButtonStyled>
</section>
</template>
</div>
</template>
<script setup>
import { RightArrowIcon, SettingsIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()

View File

@@ -146,7 +146,7 @@
before proceeding.
</p>
<p v-if="blockedByTax" class="font-bold text-orange">
<p v-else-if="blockedByTax" class="font-bold text-orange">
You have withdrawn over $600 this year. To continue withdrawing, you must complete a tax form.
</p>

View File

@@ -4,7 +4,7 @@ import { articles as rawArticles } from '@modrinth/blog'
import { Avatar, ButtonStyled } from '@modrinth/ui'
import type { User } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import NewsletterButton from '~/components/ui/NewsletterButton.vue'
import ShareArticleButtons from '~/components/ui/ShareArticleButtons.vue'
@@ -74,6 +74,36 @@ useSeoMeta({
twitterCard: 'summary_large_image',
twitterImage: () => thumbnailPath.value,
})
onMounted(() => {
const videos = document.querySelectorAll('.markdown-body video')
if ('IntersectionObserver' in window) {
const videoObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const video = entry.target as HTMLVideoElement
if (entry.isIntersecting) {
video.play().catch(() => {})
} else {
video.pause()
}
})
},
{
threshold: 0.5,
},
)
videos.forEach((video) => {
videoObserver.observe(video)
})
} else {
videos.forEach((video) => {
;(video as HTMLVideoElement).setAttribute('autoplay', '')
})
}
})
</script>
<template>
@@ -181,14 +211,19 @@ useSeoMeta({
padding: 0;
}
ul,
ul > li:not(:last-child),
ol > li:not(:last-child) {
margin-bottom: 0.5rem;
}
ul,
ol {
p {
> li > p {
margin-top: 0;
margin-bottom: 0;
}
> li > p:not(:last-child) {
margin-bottom: 0.5rem;
}
}
@@ -220,20 +255,22 @@ useSeoMeta({
h2 {
font-size: 1.25rem;
margin-top: 1.5rem;
@media (min-width: 640px) {
font-size: 1.5rem;
}
}
h3 {
font-size: 1.125rem;
font-size: 1rem;
margin-top: 1.25rem;
@media (min-width: 640px) {
font-size: 1.25rem;
font-size: 1.125rem;
}
}
p {
margin-bottom: 1.25rem;
margin-bottom: 0.75rem;
font-size: 0.875rem;
@media (min-width: 640px) {
font-size: 1rem;
@@ -275,8 +312,34 @@ useSeoMeta({
}
}
hr {
border: none;
height: 1px;
background-color: var(--color-divider);
}
.video-wrapper {
display: inline-block;
max-width: 100%;
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--color-button-border);
@media (min-width: 640px) {
border-radius: var(--radius-lg);
}
video {
display: block;
max-width: 100%;
object-fit: cover;
height: auto;
}
}
> img,
> :has(img:first-child:last-child) {
> .video-wrapper,
> :has(img:first-child:last-child),
> :has(video:first-child:last-child) {
display: flex;
justify-content: center;
}

View File

@@ -151,6 +151,7 @@
:server-name="serverData.name"
:server-data="serverData"
:uptime-seconds="uptimeSeconds"
:backup-in-progress="backupInProgress"
@action="sendPowerAction"
/>
</div>
@@ -354,7 +355,7 @@
>
<h2 class="m-0 text-lg font-extrabold text-contrast">Server data</h2>
<pre class="markdown-body w-full overflow-auto rounded-2xl bg-bg-raised p-4 text-sm">{{
JSON.stringify(server, null, ' ')
JSON.stringify(server, null, ' ')
}}</pre>
</div>
</template>
@@ -759,9 +760,14 @@ const handleWebSocketMessage = (data: WSEvent) => {
curBackup.task = {}
}
curBackup.task[data.task] = {
progress: data.progress,
state: data.state,
const currentState = curBackup.task[data.task]?.state
const shouldUpdate = !(currentState === 'ongoing' && data.state === 'unchanged')
if (shouldUpdate) {
curBackup.task[data.task] = {
progress: data.progress,
state: data.state,
}
}
curBackup.ongoing = data.task === 'create' && data.state === 'ongoing'
@@ -1037,7 +1043,10 @@ const nodeUnavailableDetails = computed(() => [
},
{
label: 'Node',
value: server.general?.datacenter ?? 'Unknown',
value:
server.moduleErrors?.general?.error.responseData?.hostname ??
server.general?.datacenter ??
'Unknown',
type: 'inline' as const,
},
{
@@ -1277,6 +1286,7 @@ useHead({
opacity: 0;
transform: translateX(1rem);
}
100% {
opacity: 1;
transform: none;

View File

@@ -105,6 +105,7 @@
v-for="backup in backups"
:key="`backup-${backup.id}`"
:backup="backup"
:server="props.server"
:kyros-url="props.server.general?.node.instance"
:jwt="props.server.general?.node.token"
@download="() => triggerDownloadAnimation()"

View File

@@ -16,7 +16,7 @@
<ButtonStyled>
<button @click="cancelRoleEdit">
<XIcon />
Cancel
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
@@ -25,9 +25,11 @@
@click="saveRoleEdit"
>
<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 v-else> <SaveIcon /> Save changes </template>
</button>
</ButtonStyled>
</div>
@@ -36,10 +38,16 @@
<NewModal v-if="auth.user && isStaff(auth.user)" ref="userDetailsModal" header="User details">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary">Email</span>
<span class="text-lg font-bold text-primary">{{
formatMessage(messages.emailLabel)
}}</span>
<div>
<span
v-tooltip="user.email_verified ? 'Email verified' : 'Email not verified'"
v-tooltip="
user.email_verified
? formatMessage(messages.emailVerifiedTooltip)
: formatMessage(messages.emailNotVerifiedTooltip)
"
class="flex w-fit items-center gap-1"
>
<span>{{ user.email }}</span>
@@ -50,12 +58,16 @@
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary"> Auth providers </span>
<span class="text-lg font-bold text-primary">{{
formatMessage(messages.authProvidersLabel)
}}</span>
<span>{{ user.auth_providers.join(', ') }}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary"> Payment methods</span>
<span class="text-lg font-bold text-primary">{{
formatMessage(messages.paymentMethodsLabel)
}}</span>
<span>
<template v-if="user.payout_data?.paypal_address">
Paypal ({{ user.payout_data.paypal_address }} - {{ user.payout_data.paypal_country }})
@@ -70,16 +82,22 @@
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary"> Has password </span>
<span class="text-lg font-bold text-primary">{{
formatMessage(messages.hasPasswordLabel)
}}</span>
<span>
{{ user.has_password ? 'Yes' : 'No' }}
{{
user.has_password ? formatMessage(messages.yesLabel) : formatMessage(messages.noLabel)
}}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary"> Has TOTP </span>
<span class="text-lg font-bold text-primary">{{
formatMessage(messages.hasTotpLabel)
}}</span>
<span>
{{ user.has_totp ? 'Yes' : 'No' }}
{{ user.has_totp ? formatMessage(messages.yesLabel) : formatMessage(messages.noLabel) }}
</span>
</div>
</div>
@@ -98,8 +116,8 @@
user.bio
? user.bio
: projects.length === 0
? 'A Modrinth user.'
: 'A Modrinth creator.'
? formatMessage(messages.bioFallbackUser)
: formatMessage(messages.bioFallbackCreator)
}}
</template>
<template #stats>
@@ -107,16 +125,22 @@
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
>
<BoxIcon class="h-6 w-6 text-secondary" />
{{ formatCompactNumber(projects?.length || 0) }}
projects
{{
formatMessage(messages.profileProjectsLabel, {
count: formatCompactNumber(projects?.length || 0),
})
}}
</div>
<div
v-tooltip="sumDownloads.toLocaleString()"
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
>
<DownloadIcon class="h-6 w-6 text-secondary" />
{{ formatCompactNumber(sumDownloads) }}
downloads
{{
formatMessage(messages.profileDownloadsLabel, {
count: formatCompactNumber(sumDownloads),
})
}}
</div>
<div
v-tooltip="
@@ -128,7 +152,7 @@
class="flex items-center gap-2 font-semibold"
>
<CalendarIcon class="h-6 w-6 text-secondary" />
Joined
{{ formatMessage(messages.profileJoinedLabel) }}
{{ formatRelativeTime(user.created) }}
</div>
</template>
@@ -287,7 +311,7 @@
<h2 class="title">{{ collection.name }}</h2>
<div class="stats">
<LibraryIcon aria-hidden="true" />
Collection
{{ formatMessage(messages.collectionLabel) }}
</div>
</div>
</div>
@@ -298,25 +322,27 @@
<div class="stats">
<BoxIcon />
{{
`${$formatNumber(collection.projects?.length || 0, false)} project${(collection.projects?.length || 0) !== 1 ? 's' : ''}`
`${$formatNumber(collection.projects?.length || 0, false)} project${
(collection.projects?.length || 0) !== 1 ? 's' : ''
}`
}}
</div>
<div class="stats">
<template v-if="collection.status === 'listed'">
<GlobeIcon />
<span> Public </span>
<span> {{ formatMessage(commonMessages.publicLabel) }} </span>
</template>
<template v-else-if="collection.status === 'unlisted'">
<LinkIcon />
<span> Unlisted </span>
<span> {{ formatMessage(commonMessages.unlistedLabel) }} </span>
</template>
<template v-else-if="collection.status === 'private'">
<LockIcon />
<span> Private </span>
<span> {{ formatMessage(commonMessages.privateLabel) }} </span>
</template>
<template v-else-if="collection.status === 'rejected'">
<XIcon />
<span> Rejected </span>
<span> {{ formatMessage(commonMessages.rejectedLabel) }} </span>
</template>
</div>
</div>
@@ -449,25 +475,75 @@ const formatRelativeTime = useRelativeTime()
const { addNotification } = injectNotificationManager()
const messages = defineMessages({
profileProjectsStats: {
id: 'profile.stats.projects',
defaultMessage:
'{count, plural, one {<stat>{count}</stat> project} other {<stat>{count}</stat> projects}}',
profileProjectsLabel: {
id: 'profile.label.projects',
defaultMessage: '{count} {count, plural, one {project} other {projects}}',
},
profileDownloadsStats: {
id: 'profile.stats.downloads',
defaultMessage:
'{count, plural, one {<stat>{count}</stat> project download} other {<stat>{count}</stat> project downloads}}',
profileDownloadsLabel: {
id: 'profile.label.downloads',
defaultMessage: '{count} {count, plural, one {download} other {downloads}}',
},
profileJoinedLabel: {
id: 'profile.label.joined',
defaultMessage: 'Joined',
},
savingLabel: {
id: 'profile.label.saving',
defaultMessage: 'Saving...',
},
emailLabel: {
id: 'profile.details.label.email',
defaultMessage: 'Email',
},
emailVerifiedTooltip: {
id: 'profile.details.tooltip.email-verified',
defaultMessage: 'Email verified',
},
emailNotVerifiedTooltip: {
id: 'profile.details.tooltip.email-not-verified',
defaultMessage: 'Email not verified',
},
authProvidersLabel: {
id: 'profile.details.label.auth-providers',
defaultMessage: 'Auth providers',
},
paymentMethodsLabel: {
id: 'profile.details.label.payment-methods',
defaultMessage: 'Payment methods',
},
hasPasswordLabel: {
id: 'profile.details.label.has-password',
defaultMessage: 'Has password',
},
hasTotpLabel: {
id: 'profile.details.label.has-totp',
defaultMessage: 'Has TOTP',
},
yesLabel: {
id: 'profile.label.yes',
defaultMessage: 'Yes',
},
noLabel: {
id: 'profile.label.no',
defaultMessage: 'No',
},
bioFallbackUser: {
id: 'profile.bio.fallback.user',
defaultMessage: 'A Modrinth user.',
},
bioFallbackCreator: {
id: 'profile.bio.fallback.creator',
defaultMessage: 'A Modrinth creator.',
},
collectionLabel: {
id: 'profile.label.collection',
defaultMessage: 'Collection',
},
profileProjectsFollowersStats: {
id: 'profile.stats.projects-followers',
defaultMessage:
'{count, plural, one {<stat>{count}</stat> project follower} other {<stat>{count}</stat> project followers}}',
},
profileJoinedAt: {
id: 'profile.joined-at',
defaultMessage: 'Joined <date>{ago}</date>',
},
profileUserId: {
id: 'profile.user-id',
defaultMessage: 'User ID: {id}',

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": [
{
"title": "More Ways to Withdraw",
"summary": "Coming soon: new withdraw options and a redesigned revenue dashboard",
"thumbnail": "https://modrinth.com/news/article/creator-withdrawals-overhaul/thumbnail.webp",
"date": "2025-10-27T23:30:00.000Z",
"link": "https://modrinth.com/news/article/creator-withdrawals-overhaul"
},
{
"title": "Standing By Our Values",
"summary": "Keeping LGBTQIA+ content visible despite demands from Russia.",

File diff suppressed because one or more lines are too long

View File

@@ -33,7 +33,6 @@
}
#modrinth-rail-1 {
border-radius: 1rem;
position: absolute;
left: 0;
top: 0;

View File

@@ -90,10 +90,10 @@ import StyledDoc from '../shared/StyledDoc.vue'
Purpose of Payment
</Text>
<Text class="m-0 text-sm leading-relaxed text-secondary">
This payout reflects revenue earned by the creator through their activity on the Modrinth
platform. Earnings are based on advertising revenue, subscriptions, and/or affiliate
commissions tied to the creator's published projects, in accordance with the Rewards Program
Terms.
This payout reflects the creator's earnings from their activity on the Modrinth platform.
Such earnings are based on advertising revenue derived from user engagement with the
creator's published projects and/or affiliate commissions in accordance with the Rewards
Program Terms.
</Text>
</Section>

View File

@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
Modrinth account.
</Text>
<Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

View File

@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
sign in to your Modrinth account.
</Text>
<Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

View File

@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
{emailchanged.new_email}.
</Text>
<Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

View File

@@ -70,9 +70,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
<Text class="text-muted text-base">
If this wasn't you, please update your password and review your account security settings. If
you cannot do this, contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
you cannot do this, contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

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.
</Text>
<Text class="text-muted text-base">
If you did not create this token, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not create this token, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

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"> Your password has been changed on your account. </Text>
<Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

View File

@@ -18,9 +18,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
Modrinth account.</Text
>
<Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

View File

@@ -34,8 +34,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
<Text class="text-base">
If you have any questions about the creator rewards program, please contact support through
the
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink>
or by replying to this email.
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
<Text class="text-base">Thank you for being a creator on Modrinth!</Text>

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">
Thank you for choosing Modrinth! If you have any questions or need help with your
subscription, reply to this email or visit our
subscription, visit our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>

View File

@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
need to submit the code generated by your authenticator app.
</Text>
<Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

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.
</Text>
<Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

View File

@@ -18,6 +18,7 @@ export default {
// Subscriptions
'subscription-tax-change': () => import('./account/SubscriptionTaxChange.vue'),
'subscription-credited': () => import('./account/SubscriptionCredited.vue'),
// Moderation
'report-submitted': () => import('./moderation/ReportSubmitted.vue'),

View File

@@ -68,8 +68,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
in error or is abusive, please contact support
<VLink href="https://support.modrinth.com" class="text-green underline">
through the Support Portal</VLink
>
or by replying to this email.
>.
</Text>
</StyledEmail>
</template>

View File

@@ -63,8 +63,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
this was sent in error or is abusive, please contact support
<VLink href="https://support.modrinth.com" class="text-green underline">
through the Support Portal</VLink
>
or by replying to this email.
>.
</Text>
</StyledEmail>
</template>

View File

@@ -45,8 +45,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
</Button>
<Text class="text-base">
If you have questions or believe something isn't correct, you can reply to this email or reach
out via the
If you have questions or believe something isn't correct, you can reach out via the
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>

View File

@@ -53,8 +53,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
<Text class="text-base">
If you believe this status was applied in error, you can reply in the moderation thread or
contact support through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink>
or by replying to this email.
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
<Text class="text-base">Thank you for publishing on Modrinth!</Text>

View File

@@ -53,8 +53,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
<Text class="text-base">
If you did not initiate this transfer, please contact support immediately through the
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink>
or by replying to this email.
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>
</template>

View File

@@ -307,6 +307,7 @@ export const useFetchAllAnalytics = (
startDate = ref(dayjs().subtract(30, 'days')),
endDate = ref(dayjs()),
timeResolution = ref(1440),
isInitialized = ref(false),
) => {
const downloadData = ref(null)
const viewData = ref(null)
@@ -388,8 +389,18 @@ export const useFetchAllAnalytics = (
}
watch(
[() => startDate.value, () => endDate.value, () => timeResolution.value, () => projects.value],
[
() => startDate.value,
() => endDate.value,
() => timeResolution.value,
() => projects.value,
() => isInitialized.value,
],
async () => {
if (!isInitialized.value) {
return
}
const q = {
start_date: startDate.value.toISOString(),
end_date: endDate.value.toISOString(),
@@ -456,5 +467,6 @@ export const useFetchAllAnalytics = (
totalData,
loading,
error,
isInitialized,
}
}

View File

@@ -114,6 +114,7 @@ CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=default
CLICKHOUSE_DATABASE=staging_ariadne
MAXMIND_ACCOUNT_ID=none
MAXMIND_LICENSE_KEY=none
FLAME_ANVIL_URL=none
@@ -141,4 +142,7 @@ COMPLIANCE_PAYOUT_THRESHOLD=disabled
ANROK_API_KEY=none
ANROK_API_URL=none
GOTENBERG_URL=http://labrinth-gotenberg:13000
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
ARCHON_URL=none

View File

@@ -115,6 +115,7 @@ CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=default
CLICKHOUSE_DATABASE=staging_ariadne
MAXMIND_ACCOUNT_ID=none
MAXMIND_LICENSE_KEY=none
FLAME_ANVIL_URL=none
@@ -142,4 +143,7 @@ COMPLIANCE_PAYOUT_THRESHOLD=disabled
ANROK_API_KEY=none
ANROK_API_URL=none
GOTENBERG_URL=http://localhost:13000
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
ARCHON_URL=none

View File

@@ -113,6 +113,10 @@ migrations/20250725230041_reports-closed-status-index.sql
migrations/20250727184120_user-newsletter-subscription-column.sql
migrations/20250804221014_users-redeemals.sql
migrations/20250805001654_product-prices-public.sql
migrations/20250823233518_user-compliance.sql
migrations/20250902133943_notification-extension.sql
migrations/20250914190749_affiliate_codes.sql
migrations/20250927120742_user_limits.sql
# Prettier reformats some of the PostgreSQL-specific COPY syntax here,
# which is very likely to break things

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n status = 'failed' AND due < NOW() - INTERVAL '30 days'\n ",
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\",\n charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n \n INNER JOIN users_subscriptions us ON us.id = charges.subscription_id\n WHERE\n charges.charge_type = $1 AND\n (\n (charges.status = 'cancelled' AND charges.due < NOW()) OR\n (charges.status = 'expiring' AND charges.due < NOW()) OR\n (charges.status = 'failed' AND charges.last_attempt < NOW() - INTERVAL '2 days')\n )\n AND us.status = 'provisioned'\n ",
"describe": {
"columns": [
{
@@ -97,6 +97,16 @@
"ordinal": 18,
"name": "tax_drift_loss?",
"type_info": "Int8"
},
{
"ordinal": 19,
"name": "tax_transaction_version?",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "tax_platform_accounting_time?",
"type_info": "Timestamptz"
}
],
"parameters": {
@@ -123,8 +133,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "e36e0ac1e2edb73533961a18e913f0b8e4f420a76e511571bb2eed9355771e54"
"hash": "372c03a6daf0045f615faa9a6205558cd0ea1d9dba5948e8fa2496ed99de8fea"
}

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",
"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": {
"columns": [
{
@@ -97,11 +97,20 @@
"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"
]
},
@@ -124,8 +133,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "050e755134f6d1f09de805ae2cd0f7ca8f6efb96be9f070c43db7fd2049af2d2"
"hash": "4a0e5c7ebd4565b95fb99983484cec76952f1505a75eb1a006b3ad9b8aa91a51"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n INNER JOIN users_subscriptions us ON us.id = charges.subscription_id\n WHERE\n charges.charge_type = $1 AND\n (\n (charges.status = 'cancelled' AND charges.due < NOW()) OR\n (charges.status = 'expiring' AND charges.due < NOW()) OR\n (charges.status = 'failed' AND charges.last_attempt < NOW() - INTERVAL '2 days')\n )\n AND us.status = 'provisioned'\n ",
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\",\n charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'open' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
"describe": {
"columns": [
{
@@ -97,6 +97,16 @@
"ordinal": 18,
"name": "tax_drift_loss?",
"type_info": "Int8"
},
{
"ordinal": 19,
"name": "tax_transaction_version?",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "tax_platform_accounting_time?",
"type_info": "Timestamptz"
}
],
"parameters": {
@@ -123,8 +133,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "9f0c73fabe99d9891faaebdd3518b362437dcdcef9cd9a68b950fba61218bb4d"
"hash": "4ed57832b7c02e1f4c683e256455c76e645cde49f95b0e5bfecd3d3d2330ed5c"
}

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",
"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": {
"columns": [
{
@@ -97,6 +97,16 @@
"ordinal": 18,
"name": "tax_drift_loss?",
"type_info": "Int8"
},
{
"ordinal": 19,
"name": "tax_transaction_version?",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "tax_platform_accounting_time?",
"type_info": "Timestamptz"
}
],
"parameters": {
@@ -123,8 +133,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "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",
"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": {
"columns": [
{
@@ -97,6 +97,16 @@
"ordinal": 18,
"name": "tax_drift_loss?",
"type_info": "Int8"
},
{
"ordinal": 19,
"name": "tax_transaction_version?",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "tax_platform_accounting_time?",
"type_info": "Timestamptz"
}
],
"parameters": {
@@ -123,8 +133,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "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",
"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": {
"columns": [
{
@@ -97,6 +97,16 @@
"ordinal": 18,
"name": "tax_drift_loss?",
"type_info": "Int8"
},
{
"ordinal": 19,
"name": "tax_transaction_version?",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "tax_platform_accounting_time?",
"type_info": "Timestamptz"
}
],
"parameters": {
@@ -123,8 +133,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "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",
"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": {
"columns": [
{
@@ -97,6 +97,16 @@
"ordinal": 18,
"name": "tax_drift_loss?",
"type_info": "Int8"
},
{
"ordinal": 19,
"name": "tax_transaction_version?",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "tax_platform_accounting_time?",
"type_info": "Timestamptz"
}
],
"parameters": {
@@ -123,8 +133,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "51c542076b4b3811eb12f051294f55827a27f51e65e668525b8b545f570c0bda"
"hash": "f0618e69765ba605b1db7f25a233cbf6f13bddc80a9719cbd401395db718b2f2"
}

View File

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

View File

@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1
FROM rust:1.89.0 AS build
FROM rust:1.90.0 AS build
WORKDIR /usr/src/labrinth
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/assets /labrinth
FROM debian:bookworm-slim
FROM debian:trixie-slim
LABEL org.opencontainers.image.source=https://github.com/modrinth/code
LABEL org.opencontainers.image.title=labrinth

View File

@@ -3,24 +3,22 @@
-- 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
-- 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)
VALUES (2, 'Moderator', 'moderator@modrinth.com', 'moderator');
INSERT INTO users (id, username, email, role) VALUES (3, 'User', 'user@modrinth.com', 'developer');
INSERT INTO users (id, username, email, role)
VALUES (4, 'Friend', 'friend@modrinth.com', 'developer');
INSERT INTO users (id, username, email, role)
VALUES (5, 'Enemy', 'enemy@modrinth.com', 'developer');
VALUES ({{user_id::ADMIN}}, 'Admin', 'admin@modrinth.com', 'admin'),
({{user_id::MODERATOR}}, 'Moderator', 'moderator@modrinth.com', 'moderator'),
({{user_id::USER}}, 'User', 'user@modrinth.com', 'developer'),
({{user_id::FRIEND}}, 'Friend', 'friend@modrinth.com', 'developer'),
({{user_id::ENEMY}}, 'Enemy', 'enemy@modrinth.com', 'developer');
-- 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
-- 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) VALUES (51, 2, 'moderator-pat', 'mrp_patmoderator', $1, '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');
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');
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');
INSERT INTO pats (id, user_id, name, access_token, scopes, expires)
VALUES (50, {{user_id::ADMIN}}, 'admin-pat', '{{pat::ADMIN}}', {{all_scopes}}, '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'),
(52, {{user_id::USER}}, 'user-pat', '{{pat::USER}}', {{all_scopes}}, '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_project_types (joining_loader_id, joining_project_type_id) VALUES (5, 1);
@@ -119,7 +117,7 @@ VALUES (
1,
'oauth_client_alpha',
NULL,
$1,
{{all_scopes}},
'4dbff86cc2ca1bae1e16468a05cb9881c97f1753bce3619034898faa1aabe429955a1bf8ec483d7421fe3c1646613a59ed5441fb0f321389f77f48a879c7b1f1',
3
);

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