Compare commits

...
Author SHA1 Message Date
tdgao 63a3d12f79 Merge branch 'boris/dev-738' of github.com:modrinth/code into boris/dev-738 2026-02-06 15:13:50 -07:00
tdgao 0438804abb add success notifications 2026-02-06 15:13:47 -07:00
aecsocket 01e11b836e prepare 2026-02-06 22:05:10 +00:00
aecsocket 5501a0c6df Fix editing project team member permissions 2026-02-06 21:47:33 +00:00
JerozgenandGitHub 695233e736 Add ICU select to report strings (#5312) 2026-02-06 18:27:55 +00:00
Calum H.andGitHub 8789d7b057 fix: 5258 (#5310)
* fix: 5258

* fix: lint
2026-02-06 18:21:41 +00:00
lumiscosityandGitHub 510ea6cde4 Enable support for Filipino, Indonesian, Korean, Dutch, and Vietnamese (#5305)
All five of these have now crossed 80% translation completion!

Signed-off-by: lumiscosity <averyrudelphe@gmail.com>
2026-02-05 14:52:38 +00:00
aecsocketandGitHub b1954be2c7 Ref-count Redis pool internals, fix project creation slug/ID collision (#5302)
* Ref-count Redis pool internals, fix project creation slug/ID collision

* cargo sqlx prepare
2026-02-05 05:18:33 +00:00
Prospector 9105a68923 changelog 2026-02-04 15:54:16 -08:00
Prospector 06e2f59a94 changelog 2026-02-04 14:54:17 -08:00
ProspectorandGitHub ddb013e024 translatable category headers (#5301) 2026-02-04 14:47:35 -08:00
aecsocketandGitHub 3f5e3b1d8b Disable login captcha if backend has no captcha secret (#5288)
* Add /_internal/globals route

* Don't show login captcha if backend claims it's disabled

* try to re-add tombi

* typos

* Assume captcha enabled if globals route is unreachable

* Prepare frontend fixes
2026-02-04 18:08:14 +00:00
323090966b feat: app server projects modals + modal borders (#5256)
* feat: add modals

* NewModal add stroke

* update diff type sorting

* update icon to match figma

* fix lint ci issues

* remove formatCategory

* feature flag on buttons

* prepr

* consistent modal borders

* intl

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-02-04 07:27:25 -08:00
Calum H.andGitHub 16204d30f8 fix: withdraw flow fixes (#5296)
* fix: dev-741 currency exchanging bug

* fix: remove redundant balance available check

* fix: lint/fmt

* fix: #5245

* fix: hide max if it's less than min
2026-02-04 14:56:14 +00:00
JerozgenandGitHub 34cbc7e0c1 Use numeric: always for Italian and Russian (#5293)
* Use `numeric: always` for Italian and Russian

* Use RelativeTimeFormatNumeric type
2026-02-04 13:49:21 +00:00
aecsocketandGitHub 5d6593a9da Add more Prometheus metrics for memory and Tokio tasks (#5282)
* Add more Prometheus metrics for memory and Tokio tasks

* pr comments
2026-02-03 19:05:34 +00:00
Prospector ab753a82bc changelog 2026-02-03 09:11:50 -08:00
Truman GaoandGitHub 880e759336 fix: ensure legacy texture pack is checking .zip file extension (#5284) 2026-02-03 04:38:56 +00:00
Prospector 84b386141c Fix formatLoader 2026-02-02 16:57:15 -08:00
Prospector e9a4cc60ca update ads.txt 2026-02-02 16:55:44 -08:00
aecsocketandGitHub d5869c514e Use deadpool fork with tracing (#5276)
* Use deadpool fork with tracing

* Implement minimum Redis connections

* fix typos maybe

* address pr comments
2026-02-02 23:24:55 +00:00
Prospector de0a03b2e9 changelog 2026-02-02 13:17:22 -08:00
3f6c79b00d Application & pat ui improvements (#5271)
* Add categories, make localizable

* Run fix

* Run prepr

* Improve pat modal ui

* Fix pat token actions

* Make scope category localization shared

* Fix casing

* Fix casing

---------

Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-02-02 18:20:28 +00:00
ProspectorandGitHub 56c8bb1950 Remove legacy formatCategory (#5280)
* Remove legacy formatCategory

* prepr
2026-02-02 18:20:23 +00:00
François-Xavier TalbotandGitHub f81f951814 Don't persist read search client across reqs (#5277) 2026-02-02 14:09:00 +00:00
François-Xavier TalbotandGitHub 345ada27c0 Increase/make configurable search timeouts (#5261)
* Increase default operation timeout, make it configurable via SEARCH_OPERATION_TIMEOUT

* Don't update index settings when unneeded

* Add to env local
2026-02-02 13:27:29 +00:00
François-Xavier TalbotandGitHub e3395a7366 Development app builds (#5255)
* Workflow changes

* Propagate app_identifier via State

* Remove old updater stuff

* Remove patch file

* Remove unused binding

* Fix application loading

* Don't sign windows binaries by default

* Remove the damn emojis

* Figure out where windows artifacts are

* Fix windows artifacts
2026-02-02 13:10:23 +00:00
78 changed files with 2092 additions and 549 deletions
+11 -9
View File
@@ -13,13 +13,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: crate-ci/typos@master
- uses: crate-ci/typos@v1.43.1
# broken: <https://github.com/SchemaStore/schemastore/issues/5108>
# tombi:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: tombi-toml/setup-tombi@v1
# - run: tombi lint
# - run: tombi fmt --check
# see <https://github.com/influxdata/datafusion-udf-wasm/pull/275>
tombi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: taiki-e/install-action@v2
with:
tool: tombi
- run: tombi lint
- run: tombi fmt --check
+39 -18
View File
@@ -19,7 +19,7 @@ on:
sign-windows-binaries:
description: Sign Windows binaries
type: boolean
default: true
default: false
required: false
jobs:
@@ -40,33 +40,48 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- name: 📥 Check out code
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 🧰 Setup Rust toolchain
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
rustflags: ''
target: ${{ startsWith(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
- name: 🧰 Install pnpm
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: 🧰 Setup Node.js
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: 🧰 Install Linux build dependencies
- name: Generate tauri-dev.conf.json
shell: bash
run: |
GIT_HASH=$(git rev-parse --short HEAD)
cat > apps/app/tauri-dev.conf.json <<EOF
{
"productName": "Modrinth App (dev-${GIT_HASH})",
"mainBinaryName": "Modrinth App (dev-${GIT_HASH})",
"identifier": "ModrinthApp-dev-${GIT_HASH}",
"bundle": {
"fileAssociations": []
}
}
EOF
- name: Install Linux build dependencies
if: startsWith(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
- name: 🧰 Setup Dasel
- name: Setup Dasel
uses: jaxxstorm/action-install-gh-release@v2.1.0
with:
repo: TomWright/dasel
@@ -75,7 +90,7 @@ jobs:
rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
chmod: 0755
- name: ⚙️ Set application version and environment
- name: Set application version and environment
shell: bash
run: |
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
@@ -86,13 +101,13 @@ jobs:
cp packages/app-lib/.env.prod packages/app-lib/.env
- name: 💨 Setup Turbo cache
- name: Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
- name: 🧰 Install dependencies
- name: Install dependencies
run: pnpm install
- name: ✍️ Set up Windows code signing
- name: Set up Windows code signing
if: startsWith(matrix.platform, 'windows')
shell: bash
run: |
@@ -102,8 +117,8 @@ jobs:
dasel delete -f apps/app/tauri-release.conf.json 'bundle.windows.signCommand'
fi
- name: 🔨 Build macOS app
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
- name: Build macOS app
run: ${{ github.ref == 'refs/heads/main' && 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-dev.conf.json' }}
if: startsWith(matrix.platform, 'macos')
env:
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
@@ -116,19 +131,19 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: 🔨 Build Linux app
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
- name: Build Linux app
run: ${{ github.ref == 'refs/heads/main' && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json' }}
if: startsWith(matrix.platform, 'ubuntu')
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: 🔨 Build Windows app
- name: Build Windows app
run: |
[System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream
$env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD"
$env:JAVA_HOME = "$env:JAVA_HOME_17_X64"
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater'
${{ github.ref == 'refs/heads/main' && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles "nsis,updater"' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json --verbose --bundles "nsis,updater"' }}
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
if: startsWith(matrix.platform, 'windows')
env:
@@ -138,7 +153,7 @@ jobs:
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64 }}
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD }}
- name: 📤 Upload app bundles
- name: Upload app bundles
uses: actions/upload-artifact@v4
with:
name: App bundle (${{ matrix.artifact-target-name }})
@@ -146,7 +161,13 @@ jobs:
target/release/bundle/appimage/Modrinth App_*.AppImage*
target/release/bundle/deb/Modrinth App_*.deb*
target/release/bundle/rpm/Modrinth App-*.rpm*
target/release/bundle/appimage/Modrinth App (dev-*)_*.AppImage*
target/release/bundle/deb/Modrinth App (dev-*)_*.deb*
target/release/bundle/rpm/Modrinth App (dev-*)-*.rpm*
target/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz*
target/universal-apple-darwin/release/bundle/macos/Modrinth App (dev-*)*.app.tar.gz
target/universal-apple-darwin/release/bundle/dmg/Modrinth App (dev-*)*.dmg
target/universal-apple-darwin/release/bundle/dmg/Modrinth App_*.dmg*
target/release/bundle/nsis/Modrinth App_*-setup.exe*
target/release/bundle/nsis/Modrinth App (dev-*)_*-setup.exe*
target/release/bundle/nsis/Modrinth App_*-setup.nsis.zip*
Generated
+7 -9
View File
@@ -2247,30 +2247,28 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "deadpool"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
source = "git+https://github.com/modrinth/deadpool?rev=db5fb00b036ecc8fe5f18853c559b745ffe47bde#db5fb00b036ecc8fe5f18853c559b745ffe47bde"
dependencies = [
"deadpool-runtime",
"lazy_static",
"num_cpus",
"tokio",
"tracing",
]
[[package]]
name = "deadpool-redis"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0965b977f1244bc3783bb27cd79cfcff335a8341da18f79232d00504b18eb1a"
version = "0.22.1"
source = "git+https://github.com/modrinth/deadpool?rev=db5fb00b036ecc8fe5f18853c559b745ffe47bde#db5fb00b036ecc8fe5f18853c559b745ffe47bde"
dependencies = [
"deadpool",
"redis",
"tracing",
]
[[package]]
name = "deadpool-runtime"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
version = "0.1.5"
source = "git+https://github.com/modrinth/deadpool?rev=db5fb00b036ecc8fe5f18853c559b745ffe47bde#db5fb00b036ecc8fe5f18853c559b745ffe47bde"
dependencies = [
"tokio",
]
+1 -1
View File
@@ -60,7 +60,7 @@ const_format = "0.2.34"
daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0"
data-url = "0.3.2"
deadpool-redis = "0.22.0"
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "db5fb00b036ecc8fe5f18853c559b745ffe47bde", version = "0.22.1" }
derive_more = "2.0.1"
directories = "6.0.0"
dirs = "6.0.0"
+4
View File
@@ -17,3 +17,7 @@ extend-exclude = [
tou = "tou"
# Google Ad Manager
gam = "gam"
# short for "constants"
consts = "consts"
# short for "Copy"
Cpy = "Cpy"
+1 -1
View File
@@ -1002,7 +1002,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<transition name="popup-survey">
<div
v-if="availableSurvey"
class="w-[400px] z-20 fixed -bottom-12 pb-16 right-[--right-bar-width] mr-4 rounded-t-2xl card-shadow bg-bg-raised border-divider border-[1px] border-solid border-b-0 p-4"
class="w-[400px] z-20 fixed -bottom-12 pb-16 right-[--right-bar-width] mr-4 rounded-t-2xl card-shadow bg-bg-raised border-surface-5 border-[1px] border-solid border-b-0 p-4"
>
<h2 class="text-lg font-extrabold mt-0 mb-2">Hey there Modrinth user!</h2>
<p class="m-0 leading-tight">
@@ -10,8 +10,13 @@ import {
TrashIcon,
XIcon,
} from '@modrinth/assets'
import { Button, DropdownSelect, injectNotificationManager } from '@modrinth/ui'
import { formatCategoryHeader } from '@modrinth/utils'
import {
Button,
DropdownSelect,
formatLoader,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
@@ -23,6 +28,8 @@ import { duplicate, remove } from '@/helpers/profile.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const props = defineProps({
instances: {
type: Array,
@@ -175,7 +182,7 @@ const filteredResults = computed(() => {
if (group === 'Loader') {
instances.forEach((instance) => {
const loader = formatCategoryHeader(instance.loader)
const loader = formatLoader(formatMessage, instance.loader)
if (!instanceMap.has(loader)) {
instanceMap.set(loader, [])
}
@@ -65,7 +65,7 @@ const messages = defineMessages({
<template>
<div
v-if="availableUpdate && !dismissed"
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-divider border-solid border-[2px]"
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-surface-5 border-solid border-[2px]"
>
<div class="flex min-w-[25rem] gap-4">
<h2 class="whitespace-nowrap text-base text-contrast font-semibold m-0 grow">
@@ -68,7 +68,7 @@ const messages = defineMessages({
</script>
<template>
<div
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-divider border-solid border-[2px]"
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-surface-5 border-solid border-[2px]"
:class="{
'download-complete': progress === 1,
}"
@@ -28,7 +28,7 @@
:custom-label="
(version) =>
`${version?.name} (${version?.loaders
.map((name) => formatCategory(name))
.map((name) => formatLoader(formatMessage, name))
.join(', ')} - ${version?.game_versions.join(', ')})`
"
:max-height="150"
@@ -36,7 +36,9 @@
<span v-else>
<span>
{{ selectedVersion?.name }} ({{
selectedVersion?.loaders.map((name) => formatCategory(name)).join(', ')
selectedVersion?.loaders
.map((name) => formatLoader(formatMessage, name))
.join(', ')
}}
- {{ selectedVersion?.game_versions.join(', ') }})
</span>
@@ -57,8 +59,7 @@
<script setup>
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { Button, formatLoader, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import Multiselect from 'vue-multiselect'
@@ -67,6 +68,7 @@ import { trackEvent } from '@/helpers/analytics'
import { add_project_from_version as installMod } from '@/helpers/profile'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const instance = ref(null)
const project = ref(null)
@@ -0,0 +1,189 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
<div class="flex flex-col gap-6 max-w-[500px]">
<Admonition type="info" :header="formatMessage(messages.sharedServerInstance)">
{{ formatMessage(messages.serverRequiresMods) }}
</Admonition>
<div v-if="sharedBy?.name" class="flex items-center gap-2 text-sm text-secondary">
<Avatar
v-if="sharedBy?.icon_url"
:src="sharedBy.icon_url"
:alt="sharedBy.name"
size="24px"
/>
<span>
<IntlFormatted :message-id="messages.sharedByToday">
<template #~name>
<span class="font-semibold text-contrast">{{ sharedBy.name }}</span>
</template>
</IntlFormatted>
</span>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.sharedInstance) }}
</span>
<div class="flex items-center gap-3 rounded-xl bg-surface-4 p-3">
<Avatar :src="project.icon_url" :alt="project.title" size="48px" />
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-contrast">{{ project.title }}</span>
<span class="text-sm text-secondary">
{{ loaderDisplay }} {{ project.game_versions?.[0] }}
<template v-if="modCount">
· {{ formatMessage(messages.modCount, { count: modCount }) }}
</template>
</span>
</div>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button @click="handleDecline">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleAccept">
<DownloadIcon />
{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
formatLoader,
IntlFormatted,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { get_organization, get_team, get_version } from '@/helpers/cache.js'
import { install } from '@/store/install.js'
const props = defineProps<{
project: Labrinth.Projects.v2.Project
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const { formatMessage } = useVIntl()
const { data: organization } = useQuery({
queryKey: computed(() => ['organization', props.project.organization]),
queryFn: () => get_organization(props.project.organization!, 'must_revalidate'),
enabled: computed(() => !!props.project.organization),
})
const { data: teamMembers } = useQuery({
queryKey: computed(() => ['team', props.project.team]),
queryFn: () => get_team(props.project.team, 'must_revalidate'),
enabled: computed(() => !!props.project.team && !props.project.organization),
})
const sharedBy = computed(() => {
if (organization.value) {
return {
name: organization.value.name,
icon_url: organization.value.icon_url,
}
}
if (teamMembers.value) {
const owner = teamMembers.value.find((member: { is_owner: boolean }) => member.is_owner)
if (owner) {
return {
name: owner.user.username,
icon_url: owner.user.avatar_url,
}
}
}
return null
})
const loaderDisplay = computed(() => {
const loader = props.project.loaders?.[0]
if (!loader) return ''
return formatLoader(formatMessage, loader)
})
// Fetch the most recent version to get mod count from dependencies
const latestVersionId = computed(() => props.project.versions?.[0] ?? null)
const { data: latestVersion } = useQuery({
queryKey: computed(() => ['version', latestVersionId.value]),
queryFn: () => get_version(latestVersionId.value, 'must_revalidate'),
enabled: computed(() => !!latestVersionId.value),
})
const modCount = computed(() => latestVersion.value?.dependencies?.length)
async function handleAccept() {
hide()
try {
await install(props.project.id, null, null, 'ProjectPageInstallToPlayModal')
} catch (error) {
console.error('Failed to install project from InstallToPlayModal:', error)
}
}
function handleDecline() {
hide()
}
function show(e?: MouseEvent) {
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
}
const messages = defineMessages({
installToPlay: {
id: 'app.modal.install-to-play.header',
defaultMessage: 'Install to play',
},
sharedServerInstance: {
id: 'app.modal.install-to-play.shared-server-instance',
defaultMessage: 'Shared server instance',
},
serverRequiresMods: {
id: 'app.modal.install-to-play.server-requires-mods',
defaultMessage:
'This server requires mods to play. Click install to set up the required files from Modrinth.',
},
sharedByToday: {
id: 'app.modal.install-to-play.shared-by-today',
defaultMessage: '{name} shared this instance with you today.',
},
sharedInstance: {
id: 'app.modal.install-to-play.shared-instance',
defaultMessage: 'Shared instance',
},
modCount: {
id: 'app.modal.install-to-play.mod-count',
defaultMessage: '{count, plural, one {# mod} other {# mods}}',
},
installButton: {
id: 'app.modal.install-to-play.install-button',
defaultMessage: 'Install',
},
})
defineExpose({ show, hide })
</script>
@@ -0,0 +1,382 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.updateToPlay)" :closable="true" no-padding>
<div class="max-w-[500px]">
<div class="flex flex-col gap-4 p-4">
<Admonition type="warning" :header="formatMessage(messages.updateRequired)">
{{ formatMessage(messages.updateRequiredDescription, { name: instance.name }) }}
</Admonition>
<div v-if="diffs.length" class="flex flex-col gap-2">
<span v-if="publishedDate" class="text-contrast font-semibold">{{
formatMessage(messages.publishedDate, { date: publishedDate })
}}</span>
<div class="flex gap-2">
<div v-if="removedCount" class="flex gap-1 items-center">
<MinusIcon />
{{ formatMessage(messages.removedCount, { count: removedCount }) }}
</div>
<div v-if="addedCount" class="flex gap-1 items-center">
<PlusIcon />
{{ formatMessage(messages.addedCount, { count: addedCount }) }}
</div>
<div v-if="updatedCount" class="flex gap-1 items-center">
<RefreshCwIcon />
{{ formatMessage(messages.updatedCount, { count: updatedCount }) }}
</div>
</div>
</div>
</div>
<div v-if="diffs.length" class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto">
<div
v-for="diff in diffs"
:key="diff.project_id"
class="grid grid-cols-[auto_1fr_1fr_1fr] items-center min-h-10 h-10 gap-2"
>
<div class="flex flex-col justify-between items-center">
<div class="w-[1px] h-2"></div>
<PlusIcon v-if="diff.type === 'added'" />
<MinusIcon v-else-if="diff.type === 'removed'" />
<RefreshCwIcon v-else />
<div class="bg-surface-5 w-[1px] h-2 relative top-1"></div>
</div>
<div class="flex gap-1 col-span-2">
<span class="text-sm">{{ formatMessage(diffTypeMessages[diff.type]) }}</span>
<span
v-if="diff.project"
v-tooltip="diff.project.title"
class="text-sm text-contrast font-medium truncate"
>
{{ diff.project.title }}
</span>
</div>
<span
v-if="getFilename(diff.newVersion) || getFilename(diff.currentVersion)"
v-tooltip="getFilename(diff.newVersion) || getFilename(diff.currentVersion)"
class="text-xs truncate text-right"
>
{{ getFilename(diff.newVersion) || getFilename(diff.currentVersion) }}
</span>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-between gap-2">
<ButtonStyled color="red" type="transparent">
<button @click="handleReport">
<ReportIcon />
{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
<div class="flex gap-2">
<ButtonStyled>
<button @click="handleDecline">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleUpdate">
<DownloadIcon />
{{ formatMessage(commonMessages.updateButton) }}
</button>
</ButtonStyled>
</div>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
DownloadIcon,
MinusIcon,
PlusIcon,
RefreshCwIcon,
ReportIcon,
XIcon,
} from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import { get_project, get_project_many, get_version_many } from '@/helpers/cache.js'
import { update_managed_modrinth_version } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
type Dependency = Labrinth.Versions.v3.Dependency
type Version = Labrinth.Versions.v2.Version
interface BaseDiff {
project_id: string
project?: {
title: string
icon_url?: string
slug: string
}
currentVersionId?: string
newVersionId?: string
currentVersion?: Version
newVersion?: Version
}
interface AddedDiff extends BaseDiff {
type: 'added'
newVersionId: string
}
interface RemovedDiff extends BaseDiff {
type: 'removed'
}
interface UpdatedDiff extends BaseDiff {
type: 'updated'
currentVersionId: string
newVersionId: string
}
type DependencyDiff = AddedDiff | RemovedDiff | UpdatedDiff
type ProjectInfo = {
id: string
title: string
icon_url?: string
slug: string
}
const { instance } = defineProps<{
instance: GameInstance
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const diffs = ref<DependencyDiff[]>([])
const latestVersionId = ref<string | null>(null)
const latestVersion = ref<Version | null>(null)
const removedCount = computed(() => diffs.value.filter((d) => d.type === 'removed').length)
const addedCount = computed(() => diffs.value.filter((d) => d.type === 'added').length)
const updatedCount = computed(() => diffs.value.filter((d) => d.type === 'updated').length)
const publishedDate = computed(() =>
latestVersion.value?.date_published ? new Date(latestVersion.value.date_published) : null,
)
function getFilename(version?: Version): string | undefined {
return version?.files.find((f) => f.primary)?.filename
}
async function computeDependencyDiffs(
currentDeps: Dependency[],
latestDeps: Dependency[],
): Promise<DependencyDiff[]> {
const currentByProject = new Map<string, Dependency>(
currentDeps.map((d) => [d.project_id || '', d]),
)
const latestByProject = new Map<string, Dependency>(
latestDeps.map((d) => [d.project_id || '', d]),
)
const diffs: DependencyDiff[] = []
// Find added and updated dependencies
latestByProject.forEach((latestDep, projectId) => {
if (!projectId) return
const currentDep = currentByProject.get(projectId)
if (!currentDep && latestDep.version_id) {
diffs.push({ type: 'added', project_id: projectId, newVersionId: latestDep.version_id })
} else if (
currentDep?.version_id &&
latestDep?.version_id &&
currentDep?.version_id !== latestDep.version_id
) {
diffs.push({
type: 'updated',
project_id: projectId,
currentVersionId: currentDep.version_id,
newVersionId: latestDep.version_id,
})
}
})
// Find removed dependencies
currentByProject.forEach((currentDep, projectId) => {
if (!projectId) return
if (!latestByProject.has(projectId)) {
diffs.push({
type: 'removed',
project_id: projectId,
currentVersionId: currentDep.version_id,
})
}
})
// Fetch projects and versions of diffs
const allProjectIds = [...new Set(diffs.map((d) => d.project_id).filter(Boolean))]
const allVersionIds = [
...new Set(
[...diffs.map((d) => d.newVersionId), ...diffs.map((d) => d.currentVersionId)].filter(
Boolean,
),
),
] as string[]
const [projects, versions] = await Promise.all([
get_project_many(allProjectIds, 'must_revalidate'),
get_version_many(allVersionIds, 'must_revalidate'),
])
const projectMap = new Map<string, ProjectInfo>(projects.map((p: ProjectInfo) => [p.id, p]))
const versionMap = new Map<string, Version>(versions.map((v: Version) => [v.id, v]))
return diffs
.map((diff) => {
const project = projectMap.get(diff.project_id)
return {
...diff,
project: project
? { title: project.title, icon_url: project.icon_url, slug: project.slug }
: undefined,
currentVersion: diff.currentVersionId ? versionMap.get(diff.currentVersionId) : undefined,
newVersion: diff.newVersionId ? versionMap.get(diff.newVersionId) : undefined,
}
})
.sort((a, b) => {
const typeOrder = { removed: 0, added: 1, updated: 2 }
const typeCompare = typeOrder[a.type] - typeOrder[b.type]
if (typeCompare !== 0) return typeCompare
const aDate = a.newVersion?.date_published || a.currentVersion?.date_published || ''
const bDate = b.newVersion?.date_published || b.currentVersion?.date_published || ''
return dayjs(bDate).valueOf() - dayjs(aDate).valueOf()
})
}
async function checkUpdateAvailable(instance: GameInstance): Promise<DependencyDiff[] | null> {
if (!instance.linked_data) return null
try {
const project = await get_project(instance.linked_data.project_id, 'must_revalidate')
if (!project || !project.versions || project.versions.length === 0) {
return null
}
const versions = await get_version_many(project.versions, 'must_revalidate')
const sortedVersions = versions.sort(
(a: { date_published: string }, b: { date_published: string }) =>
dayjs(b.date_published).valueOf() - dayjs(a.date_published).valueOf(),
)
latestVersion.value = sortedVersions[0]
latestVersionId.value = latestVersion.value?.id || null
const currentVersionId = instance.linked_data.version_id
const currentVersion = versions.find((v: { id: string }) => v.id === currentVersionId)
// Compute dependency diffs between current and latest version
if (currentVersion && latestVersion.value) {
return await computeDependencyDiffs(
currentVersion.dependencies || [],
latestVersion.value.dependencies || [],
)
}
} catch (error) {
console.error('Error checking for updates:', error)
return null
}
return null
}
watch(
() => instance,
async () => {
const result = await checkUpdateAvailable(instance)
diffs.value = result || []
},
{ immediate: true, deep: true },
)
async function handleUpdate() {
hide()
try {
if (latestVersionId.value) {
await update_managed_modrinth_version(instance.path, latestVersionId.value)
}
} catch (error) {
console.error('Error updating instance:', error)
}
}
function handleReport() {
if (instance.linked_data?.project_id) {
openUrl(`https://modrinth.com/report?item=project&itemID=${instance.linked_data.project_id}`)
}
}
function handleDecline() {
hide()
}
function show(e?: MouseEvent) {
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
}
const messages = defineMessages({
updateToPlay: {
id: 'app.modal.update-to-play.header',
defaultMessage: 'Update to play',
},
updateRequired: {
id: 'app.modal.update-to-play.update-required',
defaultMessage: 'Update required',
},
updateRequiredDescription: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
'An update is required to play {name}. Please update to the latest version to launch the game.',
},
publishedDate: {
id: 'app.modal.update-to-play.published-date',
defaultMessage: '{date, date, long}',
},
removedCount: {
id: 'app.modal.update-to-play.removed-count',
defaultMessage: '{count} removed',
},
addedCount: {
id: 'app.modal.update-to-play.added-count',
defaultMessage: '{count} added',
},
updatedCount: {
id: 'app.modal.update-to-play.updated-count',
defaultMessage: '{count} updated',
},
})
const diffTypeMessages = defineMessages({
added: {
id: 'app.modal.update-to-play.diff-type.added',
defaultMessage: 'Added',
},
removed: {
id: 'app.modal.update-to-play.diff-type.removed',
defaultMessage: 'Removed',
},
updated: {
id: 'app.modal.update-to-play.diff-type.updated',
defaultMessage: 'Updated',
},
})
defineExpose({ show, hide })
</script>
@@ -5,6 +5,57 @@
"app.auth-servers.unreachable.header": {
"message": "Cannot reach authentication servers"
},
"app.modal.install-to-play.header": {
"message": "Install to play"
},
"app.modal.install-to-play.install-button": {
"message": "Install"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "This server requires mods to play. Click install to set up the required files from Modrinth."
},
"app.modal.install-to-play.shared-by-today": {
"message": "{name} shared this instance with you today."
},
"app.modal.install-to-play.shared-instance": {
"message": "Shared instance"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Shared server instance"
},
"app.modal.update-to-play.added-count": {
"message": "{count} added"
},
"app.modal.update-to-play.diff-type.added": {
"message": "Added"
},
"app.modal.update-to-play.diff-type.removed": {
"message": "Removed"
},
"app.modal.update-to-play.diff-type.updated": {
"message": "Updated"
},
"app.modal.update-to-play.header": {
"message": "Update to play"
},
"app.modal.update-to-play.published-date": {
"message": "{date, date, long}"
},
"app.modal.update-to-play.removed-count": {
"message": "{count} removed"
},
"app.modal.update-to-play.update-required": {
"message": "Update required"
},
"app.modal.update-to-play.update-required-description": {
"message": "An update is required to play {name}. Please update to the latest version to launch the game."
},
"app.modal.update-to-play.updated-count": {
"message": "{count} updated"
},
"app.settings.developer-mode-enabled": {
"message": "Developer mode enabled."
},
@@ -6,6 +6,10 @@
>
<ExportModal ref="exportModal" :instance="instance" />
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<ButtonStyled v-if="themeStore.featureFlags.server_project_qa">
<button @click="updateToPlayModal.show()">Update to play modal</button>
</ButtonStyled>
<ContentPageHeader>
<template #icon>
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
@@ -198,6 +202,7 @@ import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_version_many } from '@/helpers/cache.js'
@@ -229,6 +234,7 @@ const instance = ref()
const modrinthVersions = ref([])
const playing = ref(false)
const loading = ref(false)
const updateToPlayModal = ref()
async function fetchInstance() {
instance.value = await get(route.params.id).catch(handleError)
@@ -1,5 +1,6 @@
<template>
<div>
<InstallToPlayModal ref="installToPlayModal" :project="data" />
<Teleport to="#sidebar-teleport-target">
<ProjectSidebarCompatibility
:project="data"
@@ -23,6 +24,9 @@
/>
</Teleport>
<div class="flex flex-col gap-4 p-6">
<ButtonStyled v-if="themeStore.featureFlags.server_project_qa">
<button @click="installToPlayModal.show()">Install to play modal</button>
</ButtonStyled>
<InstanceIndicator v-if="instance" :instance="instance" />
<template v-if="data">
<Teleport
@@ -159,6 +163,7 @@ import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { get_project, get_team, get_version_many } from '@/helpers/cache.js'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
@@ -186,6 +191,8 @@ const instanceProjects = ref(null)
const installed = ref(false)
const installedVersion = ref(null)
const installToPlayModal = ref()
const instanceFilters = computed(() => {
if (!instance.value) {
return {}
+1
View File
@@ -6,6 +6,7 @@ export const DEFAULT_FEATURE_FLAGS = {
worlds_tab: false,
worlds_in_home: true,
servers_in_app: false,
server_project_qa: false,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
+2 -2
View File
@@ -38,10 +38,10 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
async fn main() -> theseus::Result<()> {
println!("Starting.");
let _log_guard = theseus::start_logger();
let _log_guard = theseus::start_logger("ModrinthApp");
// Initialize state
State::init().await?;
State::init("ModrinthApp".to_owned()).await?;
let worlds = get_recent_worlds(4, EnumSet::all()).await?;
for world in worlds {
+4 -1
View File
@@ -29,6 +29,9 @@ pub async fn settings_set(settings: Settings) -> Result<()> {
#[tauri::command]
pub async fn cancel_directory_change() -> Result<()> {
settings::cancel_directory_change().await?;
let state = State::get().await?;
let identifier = &state.app_identifier;
settings::cancel_directory_change(identifier).await?;
Ok(())
}
+6 -4
View File
@@ -107,10 +107,12 @@ pub async fn open_path<R: Runtime>(app: tauri::AppHandle<R>, path: PathBuf) {
#[tauri::command]
pub async fn show_launcher_logs_folder<R: Runtime>(app: tauri::AppHandle<R>) {
let path = DirectoryInfo::launcher_logs_dir().unwrap_or_default();
// failure to get folder just opens filesystem
// (ie: if in debug mode only and launcher_logs never created)
open_path(app, path).await;
if let Some(d) = DirectoryInfo::global_handle_if_ready() {
let path = d.launcher_logs_dir().unwrap_or_default();
// failure to get folder just opens filesystem
// (ie: if in debug mode only and launcher_logs never created)
open_path(app, path).await;
}
}
// Get opening command
+6 -3
View File
@@ -28,7 +28,7 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
theseus::EventState::init(app.clone()).await?;
tracing::info!("Initializing app state...");
State::init().await?;
State::init(app.config().identifier.clone()).await?;
let state = State::get().await?;
app.asset_protocol_scope()
@@ -110,7 +110,10 @@ fn main() {
RUST_LOG="theseus=trace" {run command}
*/
let _log_guard = theseus::start_logger();
let tauri_context = tauri::generate_context!();
let _log_guard = theseus::start_logger(&tauri_context.config().identifier);
tracing::info!("Initialized tracing subscriber. Loading Modrinth App!");
@@ -243,7 +246,7 @@ fn main() {
]);
tracing::info!("Initializing app...");
let app = builder.build(tauri::generate_context!());
let app = builder.build(tauri_context);
match app {
Ok(app) => {
@@ -90,7 +90,7 @@
</span>
</label>
<div
class="text-muted flex flex-col gap-2 rounded-lg border border-divider bg-button-bg p-4"
class="text-muted flex flex-col gap-2 rounded-lg border border-surface-5 bg-button-bg p-4"
>
<span>Hi {user.name},</span>
<div class="textarea-wrapper">
@@ -74,7 +74,7 @@
<div class="chart-controls">
<h2>
<span class="label__title">
{{ formatCategoryHeader(selectedChart) }}
{{ capitalizeString(selectedChart) }}
</span>
<span class="label__subtitle">
{{ formattedCategorySubtitle }}
@@ -311,7 +311,7 @@
<script setup lang="ts">
import { DownloadIcon, PaletteIcon, UpdatedIcon } from '@modrinth/assets'
import { Button, Card, DropdownSelect } from '@modrinth/ui'
import { formatCategoryHeader, formatMoney, formatNumber } from '@modrinth/utils'
import { capitalizeString, formatMoney, formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
@@ -68,7 +68,7 @@
{{ formatMoney(result?.fee || 0) }}
</span>
</div>
<div class="border-b-1 h-0 w-full rounded-full border-b border-solid border-divider" />
<div class="border-b-1 h-0 w-full rounded-full border-b border-solid border-surface-5" />
<div
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
>
@@ -90,66 +90,41 @@
</Combobox>
</div>
<span v-if="selectedMethodDetails" class="text-secondary">
{{
formatMoney(
selectedMethodCurrencyCode &&
selectedMethodCurrencyCode !== 'USD' &&
selectedMethodExchangeRate
? (fixedDenominationMin ?? effectiveMinAmount) / selectedMethodExchangeRate
: (fixedDenominationMin ?? effectiveMinAmount),
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
{{ formatMoney(displayMinUsd)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'"
>({{
formatAmountForDisplay(
fixedDenominationMin ?? effectiveMinAmount,
displayMinLocal,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
min,
{{
formatMoney(
selectedMethodCurrencyCode &&
selectedMethodCurrencyCode !== 'USD' &&
selectedMethodExchangeRate
? (fixedDenominationMax ??
selectedMethodDetails.interval?.standard?.max ??
effectiveMaxAmount) / selectedMethodExchangeRate
: (fixedDenominationMax ??
selectedMethodDetails.interval?.standard?.max ??
effectiveMaxAmount),
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
formatAmountForDisplay(
fixedDenominationMax ??
selectedMethodDetails.interval?.standard?.max ??
effectiveMaxAmount,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
min<template v-if="displayMinUsd <= roundedMaxAmount"
>, {{ formatMoney(displayMaxUsd)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'"
>({{
formatAmountForDisplay(
displayMaxLocal,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
max</template
>
max withdrawal amount.
withdrawal amount.
</span>
<span
v-if="selectedMethodDetails && effectiveMinAmount > roundedMaxAmount"
class="text-sm text-red"
>
You need at least
{{
formatMoney(
selectedMethodCurrencyCode &&
selectedMethodCurrencyCode !== 'USD' &&
selectedMethodExchangeRate
? effectiveMinAmount / selectedMethodExchangeRate
: effectiveMinAmount,
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
{{ formatMoney(displayMinUsd)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'"
>({{
formatAmountForDisplay(
effectiveMinAmount,
displayMinLocal,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
@@ -307,7 +282,19 @@
v-if="!useDenominationSuggestions && denominationOptions.length === 0"
class="text-error text-sm"
>
No denominations available for your current balance
<template v-if="rawFixedDenominationMin !== null">
The minimum denomination is
{{
formatAmountForDisplay(
rawFixedDenominationMin,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{ formatMoney(convertToUsd(rawFixedDenominationMin)) }})</template
>, which exceeds your balance of {{ formatMoney(roundedMaxAmount) }}.
</template>
<template v-else>No denominations available for your current balance</template>
</span>
</div>
@@ -324,7 +311,7 @@
<WithdrawFeeBreakdown
v-if="allRequiredFieldsFilled && formData.amount && formData.amount > 0"
:amount="formData.amount || 0"
:amount="amountForFeeBreakdown"
:fee="calculatedFee"
:fee-loading="feeLoading"
:exchange-rate="showGiftCardSelector ? selectedMethodExchangeRate : giftCardExchangeRate"
@@ -720,6 +707,34 @@ const hasSelectedDenomination = computed(() => {
)
})
// Convert local currency amount to USD using the selected method's exchange rate
const convertToUsd = (localAmount: number): number => {
const exchangeRate = selectedMethodExchangeRate.value
if (
selectedMethodCurrencyCode.value &&
selectedMethodCurrencyCode.value !== 'USD' &&
exchangeRate &&
exchangeRate > 0
) {
return localAmount / exchangeRate
}
return localAmount
}
// Convert USD amount to local currency using the selected method's exchange rate
const convertToLocalCurrency = (usdAmount: number): number => {
const exchangeRate = selectedMethodExchangeRate.value
if (
selectedMethodCurrencyCode.value &&
selectedMethodCurrencyCode.value !== 'USD' &&
exchangeRate &&
exchangeRate > 0
) {
return usdAmount * exchangeRate
}
return usdAmount
}
const denominationOptions = computed(() => {
const interval = selectedMethodDetails.value?.interval
if (!interval) return []
@@ -735,30 +750,56 @@ const denominationOptions = computed(() => {
if (values.length === 0) return []
const filtered = values.filter((amount) => amount <= roundedMaxAmount.value).sort((a, b) => a - b)
// Convert USD balance to local currency for comparison with denomination values
// (denomination values are in local currency, e.g., 50 INR, 45000 IDR)
const maxInLocalCurrency = convertToLocalCurrency(roundedMaxAmount.value)
const filtered = values.filter((amount) => amount <= maxInLocalCurrency).sort((a, b) => a - b)
debug(
'Denomination options (filtered by max):',
filtered,
'from',
values,
'max:',
'max (local currency):',
maxInLocalCurrency,
'max (USD):',
roundedMaxAmount.value,
)
return filtered
})
const effectiveMinAmount = computed(() => {
return selectedMethodDetails.value?.interval?.standard?.min || 0.01
const min = selectedMethodDetails.value?.interval?.standard?.min || 0.01
// Convert from local currency to USD for display/validation
return convertToUsd(min)
})
const effectiveMaxAmount = computed(() => {
const methodMax = selectedMethodDetails.value?.interval?.standard?.max
if (methodMax !== undefined && methodMax !== null) {
return Math.min(roundedMaxAmount.value, methodMax)
// Convert method max from local currency to USD, then compare with USD balance
const methodMaxInUsd = convertToUsd(methodMax)
return Math.min(roundedMaxAmount.value, methodMaxInUsd)
}
return roundedMaxAmount.value
})
// Get the minimum fixed denomination from the full list (not filtered by user balance)
const rawFixedDenominationMin = computed(() => {
const interval = selectedMethodDetails.value?.interval
if (!interval) return null
let values: number[] = []
if (interval.fixed?.values) {
values = [...interval.fixed.values]
} else if (interval.standard && interval.standard.min === interval.standard.max) {
values = [interval.standard.min]
}
if (values.length === 0) return null
return Math.min(...values)
})
const fixedDenominationMin = computed(() => {
if (!useFixedDenominations.value) return null
const options = denominationOptions.value
@@ -773,6 +814,63 @@ const fixedDenominationMax = computed(() => {
return options[options.length - 1]
})
// - Fixed denominations: convert from local currency to USD
// - Variable amounts: effectiveMinAmount/effectiveMaxAmount are already in USD
const displayMinUsd = computed(() => {
if (fixedDenominationMin.value !== null) {
// Fixed denomination is in local currency, convert to USD
return convertToUsd(fixedDenominationMin.value)
}
// If no affordable denominations but there are fixed values, show the raw minimum
if (rawFixedDenominationMin.value !== null) {
return convertToUsd(rawFixedDenominationMin.value)
}
// effectiveMinAmount is already in USD
return effectiveMinAmount.value
})
const displayMaxUsd = computed(() => {
if (fixedDenominationMax.value !== null) {
// Fixed denomination is in local currency, convert to USD
return convertToUsd(fixedDenominationMax.value)
}
// For variable amounts, use effectiveMaxAmount (already in USD)
// But also check if there's a method max from the interval
const methodMax = selectedMethodDetails.value?.interval?.standard?.max
if (methodMax !== undefined && methodMax !== null) {
const methodMaxUsd = convertToUsd(methodMax)
return Math.min(effectiveMaxAmount.value, methodMaxUsd)
}
return effectiveMaxAmount.value
})
// Display values in local currency (for showing in parentheses)
const displayMinLocal = computed(() => {
if (fixedDenominationMin.value !== null) {
// Fixed denomination is already in local currency
return fixedDenominationMin.value
}
// If no affordable denominations but there are fixed values, show the raw minimum
if (rawFixedDenominationMin.value !== null) {
return rawFixedDenominationMin.value
}
// Convert USD to local currency
return convertToLocalCurrency(effectiveMinAmount.value)
})
const displayMaxLocal = computed(() => {
if (fixedDenominationMax.value !== null) {
// Fixed denomination is already in local currency
return fixedDenominationMax.value
}
// Check for method max
const methodMax = selectedMethodDetails.value?.interval?.standard?.max
if (methodMax !== undefined && methodMax !== null) {
return Math.min(convertToLocalCurrency(effectiveMaxAmount.value), methodMax)
}
return convertToLocalCurrency(effectiveMaxAmount.value)
})
const selectedDenomination = computed({
get: () => formData.value.amount,
set: (value) => {
@@ -791,6 +889,21 @@ const allRequiredFieldsFilled = computed(() => {
return true
})
// Amount to display in WithdrawFeeBreakdown (expects local currency for gift cards)
const amountForFeeBreakdown = computed(() => {
const amount = formData.value.amount ?? 0
if (!showGiftCardSelector.value) {
// Non-gift-card: amount is in USD
return amount
}
if (useFixedDenominations.value) {
// Fixed denominations: amount is already in local currency
return amount
}
// Variable amount gift card: amount is in USD, convert to local currency
return convertToLocalCurrency(amount)
})
const calculateFeesDebounced = useDebounceFn(async () => {
const amount = formData.value.amount
if (!amount || amount <= 0) {
@@ -852,7 +965,15 @@ watch(
watch(
[() => formData.value.amount, selectedGiftCardId, deliveryEmail, selectedCurrency],
() => {
withdrawData.value.calculation.amount = formData.value.amount ?? 0
let amountForBackend = formData.value.amount ?? 0
// - Fixed denominations (chips): formData.amount is already in local currency
// - Variable amounts (RevenueInputField): formData.amount is in USD, needs conversion
if (showGiftCardSelector.value && !useFixedDenominations.value && amountForBackend > 0) {
amountForBackend = convertToLocalCurrency(amountForBackend)
}
withdrawData.value.calculation.amount = amountForBackend
if (showGiftCardSelector.value && selectedGiftCardId.value) {
withdrawData.value.selection.methodId = selectedGiftCardId.value
@@ -57,7 +57,7 @@
</div>
<div class="mt-auto">
<div
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-surface-5 pt-4"
>
<div class="flex items-center gap-2">
<ButtonStyled v-if="lockStatus.expired" @click="retryAcquireLock">
@@ -90,7 +90,7 @@
</div>
<div class="mt-auto">
<div
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-surface-5 pt-4"
>
<div class="flex items-center gap-2">
<ButtonStyled @click="reviewAnyway">
@@ -153,7 +153,7 @@
v-else
v-model="message"
type="text"
class="bg-bg-input h-[400px] w-full rounded-lg border border-solid border-divider px-3 py-2 font-mono text-base"
class="bg-bg-input h-[400px] w-full rounded-lg border border-solid border-surface-5 px-3 py-2 font-mono text-base"
placeholder="No message generated."
autocomplete="off"
@input="persistState"
@@ -317,7 +317,7 @@
<!-- Stage control buttons -->
<div class="mt-auto">
<div
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-surface-5 pt-4"
>
<div class="flex items-center gap-2">
<ButtonStyled v-if="!done && !generatedMessage && moderationStore.hasItems">
@@ -88,7 +88,7 @@
:open-by-default="!versionFilter"
:class="[
versionFilter ? '' : '!border-solid border-orange bg-bg-orange !text-contrast',
'flex flex-col gap-2 rounded-2xl border-2 border-dashed border-divider p-3 transition-all',
'flex flex-col gap-2 rounded-2xl border-2 border-dashed border-surface-5 p-3 transition-all',
]"
>
<p class="m-0 items-center font-bold">
@@ -19,7 +19,7 @@
Platform
</slot>
<template #option="{ option }">
{{ formatCategory(option) }}
{{ formatLoader(formatMessage, String(option)) }}
</template>
<template v-if="hasAnyUnsupportedPlatforms" #footer>
<Checkbox
@@ -58,12 +58,15 @@
<script setup lang="ts">
import { FilterIcon } from '@modrinth/assets'
import { formatLoader, useVIntl } from '@modrinth/ui'
import Checkbox from '@modrinth/ui/src/components/base/Checkbox.vue'
import ManySelect from '@modrinth/ui/src/components/base/ManySelect.vue'
import { formatCategory, type GameVersionTag, type Version } from '@modrinth/utils'
import type { GameVersionTag, Version } from '@modrinth/utils'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
const { formatMessage } = useVIntl()
export type ListedGameVersion = {
name: string
release: boolean
@@ -119,7 +122,7 @@ const hasOnlyUnsupportedPlatforms = computed(() => {
const showSupportedPlatformsOnly = ref(true)
const filterOptions = computed(() => {
const filterOptions = computed<Record<'gameVersion' | 'platform', string[]>>(() => {
const filters: Record<'gameVersion' | 'platform', string[]> = {
gameVersion: [],
platform: [],
@@ -25,7 +25,7 @@
v-if="isOpen"
ref="menuRef"
data-pyro-telepopover-root
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-divider bg-bg-raised p-2 shadow-lg"
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
:style="menuStyle"
role="menu"
tabindex="-1"
@@ -323,6 +323,57 @@ export const scopeMessages = defineMessages({
},
})
export const scopeCategoryMessages = defineMessages({
categoryUserAccount: {
id: 'scopes.category.user-account',
defaultMessage: 'User account',
},
categoryProjects: {
id: 'scopes.category.projects',
defaultMessage: 'Projects',
},
categoryVersions: {
id: 'scopes.category.versions',
defaultMessage: 'Versions',
},
categoryCollections: {
id: 'scopes.category.collections',
defaultMessage: 'Collections',
},
categoryOrganizations: {
id: 'scopes.category.organizations',
defaultMessage: 'Organizations',
},
categoryReports: {
id: 'scopes.category.reports',
defaultMessage: 'Reports',
},
categoryThreads: {
id: 'scopes.category.threads',
defaultMessage: 'Threads',
},
categoryPats: {
id: 'scopes.category.pats',
defaultMessage: 'PATs',
},
categorySessions: {
id: 'scopes.category.sessions',
defaultMessage: 'Sessions',
},
categoryNotifications: {
id: 'scopes.category.notifications',
defaultMessage: 'Notifications',
},
categoryPayouts: {
id: 'scopes.category.payouts',
defaultMessage: 'Payouts',
},
categoryAnalytics: {
id: 'scopes.category.analytics',
defaultMessage: 'Analytics',
},
})
const scopeDefinitions = [
{
id: 'USER_READ_EMAIL',
@@ -14,6 +14,8 @@ export function createMultiFileDetectors(
return {
// Legacy texture pack (pre-1.6.1)
legacyTexturePack: async (zip: JSZip): Promise<InferredVersionInfo | null> => {
if (!rawFile.name.endsWith('.zip')) return null
const packTxt = zip.file('pack.txt')
if (!packTxt) return null
+138 -6
View File
@@ -2364,10 +2364,10 @@
"message": "You've already reported {title}"
},
"report.already-reported-description": {
"message": "You have an open report for this {item} already. You can add more details to your report if you have more information to add."
"message": "You have an open report for this {item, select, project {project} version {version} user {user} other {content}} already. You can add more details to your report if you have more information to add."
},
"report.back-to-item": {
"message": "Back to {item}"
"message": "Back to {item, select, project {project} version {version} user {user} other {content}}"
},
"report.body.description": {
"message": "Include links and images if possible and relevant. Empty or insufficient reports will be closed and ignored."
@@ -2376,10 +2376,10 @@
"message": "Please provide additional context about your report"
},
"report.checking": {
"message": "Checking {item}..."
"message": "Checking {item, select, project {project} version {version} user {user} other {content}}..."
},
"report.could-not-find": {
"message": "Could not find {item}"
"message": "Could not find {item, select, project {project} version {version} user {user} other {content}}"
},
"report.for.violation": {
"message": "Violation of Modrinth <rules-link>Rules</rules-link> or <terms-link>Terms of Use</terms-link>"
@@ -2421,13 +2421,13 @@
"message": "Please report:"
},
"report.question.content-id": {
"message": "What is the ID of the {item}?"
"message": "What is the ID of the {item, select, project {project} version {version} user {user} other {content}}?"
},
"report.question.content-type": {
"message": "What type of content are you reporting?"
},
"report.question.report-reason": {
"message": "Which of Modrinth's rules is this {item} violating?"
"message": "Which of Modrinth's rules is this {item, select, project {project} version {version} user {user} other {content}} violating?"
},
"report.report-content": {
"message": "Report content to moderators"
@@ -2444,6 +2444,42 @@
"scopes.analytics.label": {
"message": "Read analytics"
},
"scopes.category.analytics": {
"message": "Analytics"
},
"scopes.category.collections": {
"message": "Collections"
},
"scopes.category.notifications": {
"message": "Notifications"
},
"scopes.category.organizations": {
"message": "Organizations"
},
"scopes.category.pats": {
"message": "PATs"
},
"scopes.category.payouts": {
"message": "Payouts"
},
"scopes.category.projects": {
"message": "Projects"
},
"scopes.category.reports": {
"message": "Reports"
},
"scopes.category.sessions": {
"message": "Sessions"
},
"scopes.category.threads": {
"message": "Threads"
},
"scopes.category.user-account": {
"message": "User account"
},
"scopes.category.versions": {
"message": "Versions"
},
"scopes.collectionCreate.description": {
"message": "Create collections"
},
@@ -2741,6 +2777,102 @@
"servers.plan.small.name": {
"message": "Small"
},
"settings.applications.about": {
"message": "About"
},
"settings.applications.button.add-more": {
"message": "Add more"
},
"settings.applications.button.add-redirect-uri": {
"message": "Add a redirect uri"
},
"settings.applications.button.cancel": {
"message": "Cancel"
},
"settings.applications.button.create": {
"message": "Create app"
},
"settings.applications.button.delete": {
"message": "Delete"
},
"settings.applications.button.edit": {
"message": "Edit"
},
"settings.applications.button.new": {
"message": "New application"
},
"settings.applications.button.save-changes": {
"message": "Save changes"
},
"settings.applications.button.upload-icon": {
"message": "Upload icon"
},
"settings.applications.client-id": {
"message": "Client ID"
},
"settings.applications.client-secret": {
"message": "Client secret"
},
"settings.applications.created-on": {
"message": "Created on {date}"
},
"settings.applications.delete.confirm.button": {
"message": "Delete this application"
},
"settings.applications.delete.confirm.description": {
"message": "This will permanently delete this application and revoke all access tokens. (forever!)"
},
"settings.applications.delete.confirm.title": {
"message": "Are you sure you want to delete this application?"
},
"settings.applications.description.intro": {
"message": "Applications can be used to authenticate Modrinth's users with your products. For more information, see <docs-link>Modrinth's API documentation</docs-link>."
},
"settings.applications.field.description": {
"message": "Description"
},
"settings.applications.field.description.placeholder": {
"message": "Enter the application's description..."
},
"settings.applications.field.icon": {
"message": "Icon"
},
"settings.applications.field.name": {
"message": "Name"
},
"settings.applications.field.name.placeholder": {
"message": "Enter the application's name..."
},
"settings.applications.field.redirect-uri.placeholder": {
"message": "https://example.com/auth/callback"
},
"settings.applications.field.redirect-uris": {
"message": "Redirect uris"
},
"settings.applications.field.scopes": {
"message": "Scopes"
},
"settings.applications.field.url": {
"message": "URL"
},
"settings.applications.field.url.placeholder": {
"message": "https://example.com"
},
"settings.applications.modal.header": {
"message": "Application information"
},
"settings.applications.notification.error.title": {
"message": "An error occurred"
},
"settings.applications.notification.icon-updated.description": {
"message": "Your application icon has been updated."
},
"settings.applications.notification.icon-updated.title": {
"message": "Icon updated"
},
"settings.applications.secret.disclaimer": {
"message": "Save your secret now, it will be hidden after you leave this page!"
},
"settings.billing.modal.cancel.action": {
"message": "Cancel subscription"
},
@@ -499,9 +499,26 @@
</div>
</template>
<div class="input-group">
<!--
if we save changes and update an org member which:
- is not currently overridden (!allOrgMembers[index].oldOverride)
- and we're not changing them to be overridden (!allOrgMembers[index].override)
then we end up editing an org member which, in the backend, doesn't exist.
the api doesn't let us do that, we can only do:
- !override -> override: POST member
- override -> !override: DELETE member
- override -> override: PATCH member
- !override -> !override: do nothing
we don't allow clicking the button in that last case.
-->
<button
class="iconified-button brand-button"
:disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
:disabled="
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(!allOrgMembers[index].oldOverride && !allOrgMembers[index].override)
"
@click="updateOrgMember(index)"
>
<SaveIcon />
@@ -565,22 +582,21 @@ function initMembers() {
const selectedMembersForOrg = orgMembers.map((partialOrgMember) => {
const foundMember = allMembers.value.find((tM) => tM.user.id === partialOrgMember.user.id)
const returnVal = foundMember ?? partialOrgMember
// If replacing a partial with a full member, we need to mark as such.
returnVal.override = !!foundMember
returnVal.oldOverride = !!foundMember
returnVal.is_owner = partialOrgMember.is_owner
return returnVal
const base = foundMember ?? partialOrgMember
return {
...base,
override: !!foundMember,
oldOverride: !!foundMember,
is_owner: partialOrgMember.is_owner,
}
})
allOrgMembers.value = selectedMembersForOrg
allTeamMembers.value = allMembers.value.filter(
(x) => !selectedMembersForOrg.some((y) => y.user.id === x.user.id),
)
allTeamMembers.value = allMembers.value
.filter((x) => !selectedMembersForOrg.some((y) => y.user.id === x.user.id))
.map((x) => ({ ...x }))
}
watch([allMembers, organization, project, currentMember], initMembers)
@@ -688,6 +704,11 @@ const removeTeamMember = async (index) => {
},
)
await updateMembers()
addNotification({
title: 'Member removed',
text: "Your project's member has been removed.",
type: 'success',
})
} catch (err) {
addNotification({
title: 'An error occurred',
@@ -748,6 +769,11 @@ const transferOwnership = async (index) => {
user_id: allTeamMembers.value[index].user.id,
},
})
addNotification({
title: 'Member ownership transferred',
text: `${allTeamMembers.value[index].user.username} is now the owner of the project.`,
type: 'success',
})
await updateMembers()
} catch (err) {
addNotification({
@@ -795,6 +821,11 @@ async function updateOrgMember(index) {
)
}
await updateMembers()
addNotification({
title: 'Member(s) updated',
text: "Your project's member(s) has been updated.",
type: 'success',
})
} catch (err) {
addNotification({
title: 'An error occurred',
@@ -38,7 +38,7 @@
<template v-for="header in Object.keys(categoryLists)" :key="`categories-${header}`">
<div class="label mb-3">
<h4>
<span class="label__title">{{ formatCategoryHeader(header) }}</span>
<span class="label__title">{{ formatCategoryHeader(formatMessage, header) }}</span>
</h4>
<span class="label__description">
<template v-if="header === 'categories'">
@@ -136,13 +136,14 @@ import { getCategoryIcon, StarIcon, TriangleAlertIcon } from '@modrinth/assets'
import {
Checkbox,
formatCategory,
formatCategoryHeader,
FormattedTag,
injectProjectPageContext,
UnsavedChangesPopup,
useSavable,
useVIntl,
} from '@modrinth/ui'
import { formatCategoryHeader, formatProjectType, sortedCategories } from '@modrinth/utils'
import { formatProjectType, sortedCategories } from '@modrinth/utils'
import { computed } from 'vue'
interface Category {
@@ -168,7 +168,7 @@
<template v-else> Unknown product </template>
</span>
<div class="mb-4 mt-2 flex w-full items-center gap-1 text-sm text-secondary">
{{ formatCategory(subscription.interval) }} ⋅ {{ subscription.status }} ⋅
{{ capitalizeString(subscription.interval) }} ⋅ {{ subscription.status }} ⋅
{{ dayjs(subscription.created).format('MMMM D, YYYY [at] h:mma') }} ({{
formatRelativeTime(subscription.created)
}})
@@ -334,7 +334,7 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { formatCategory, formatPrice } from '@modrinth/utils'
import { capitalizeString, formatPrice } from '@modrinth/utils'
import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
import dayjs from 'dayjs'
@@ -22,9 +22,13 @@
/>
</div>
<HCaptcha ref="captcha" v-model="token" />
<HCaptcha v-if="globals?.captcha_enabled" ref="captcha" v-model="token" />
<button class="btn btn-primary centered-btn" :disabled="!token" @click="recovery">
<button
class="btn btn-primary centered-btn"
:disabled="globals?.captcha_enabled ? !token : false"
@click="recovery"
>
<SendIcon /> {{ formatMessage(methodChoiceMessages.action) }}
</button>
</template>
@@ -158,6 +162,15 @@ if (route.query.flow) {
const captcha = ref()
const { data: globals } = await useAsyncData('auth-globals', async () => {
try {
return await useBaseFetch('globals', { internal: true })
} catch (err) {
console.error('Error fetching globals:', err)
return { captcha_enabled: true }
}
})
const email = ref('')
const token = ref('')
+11 -2
View File
@@ -89,11 +89,11 @@
/>
</div>
<HCaptcha ref="captcha" v-model="token" />
<HCaptcha v-if="globals?.captcha_enabled" ref="captcha" v-model="token" />
<button
class="btn btn-primary continue-btn centered-btn"
:disabled="!token"
:disabled="globals?.captcha_enabled ? !token : false"
@click="beginPasswordSignIn()"
>
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
@@ -210,6 +210,15 @@ if (auth.value.user) {
const captcha = ref()
const { data: globals } = await useAsyncData('auth-globals', async () => {
try {
return await useBaseFetch('globals', { internal: true })
} catch (err) {
console.error('Error fetching globals:', err)
return { captcha_enabled: true }
}
})
const email = ref('')
const password = ref('')
const token = ref('')
+11 -2
View File
@@ -108,11 +108,11 @@
</IntlFormatted>
</p>
<HCaptcha ref="captcha" v-model="token" />
<HCaptcha v-if="globals?.captcha_enabled" ref="captcha" v-model="token" />
<button
class="btn btn-primary continue-btn centered-btn"
:disabled="!token"
:disabled="globals?.captcha_enabled ? !token : false"
@click="createAccount"
>
{{ formatMessage(messages.createAccountButton) }} <RightArrowIcon />
@@ -209,6 +209,15 @@ if (auth.value.user) {
const captcha = ref()
const { data: globals } = await useAsyncData('auth-globals', async () => {
try {
return await useBaseFetch('globals', { internal: true })
} catch (err) {
console.error('Error fetching globals:', err)
return { captcha_enabled: true }
}
})
const email = ref('')
const username = ref('')
const password = ref('')
+11 -6
View File
@@ -559,11 +559,12 @@ const messages = defineMessages({
alreadyReportedDescription: {
id: 'report.already-reported-description',
defaultMessage:
'You have an open report for this {item} already. You can add more details to your report if you have more information to add.',
'You have an open report for this {item, select, project {project} version {version} user {user} other {content}} already. You can add more details to your report if you have more information to add.',
},
backToItem: {
id: 'report.back-to-item',
defaultMessage: 'Back to {item}',
defaultMessage:
'Back to {item, select, project {project} version {version} user {user} other {content}}',
},
goToReport: {
id: 'report.go-to-report',
@@ -609,19 +610,23 @@ const messages = defineMessages({
},
whatContentId: {
id: 'report.question.content-id',
defaultMessage: 'What is the ID of the {item}?',
defaultMessage:
'What is the ID of the {item, select, project {project} version {version} user {user} other {content}}?',
},
whatReportReason: {
id: 'report.question.report-reason',
defaultMessage: "Which of Modrinth's rules is this {item} violating?",
defaultMessage:
"Which of Modrinth's rules is this {item, select, project {project} version {version} user {user} other {content}} violating?",
},
checking: {
id: 'report.checking',
defaultMessage: 'Checking {item}...',
defaultMessage:
'Checking {item, select, project {project} version {version} user {user} other {content}}...',
},
couldNotFind: {
id: 'report.could-not-find',
defaultMessage: 'Could not find {item}',
defaultMessage:
'Could not find {item, select, project {project} version {version} user {user} other {content}}',
},
reportBodyTitle: {
id: 'report.body.title',
+267 -54
View File
@@ -2,29 +2,33 @@
<div class="universal-card">
<ConfirmModal
ref="modal_confirm"
title="Are you sure you want to delete this application?"
description="This will permanently delete this application and revoke all access tokens. (forever!)"
proceed-label="Delete this application"
:title="formatMessage(messages.deleteConfirmTitle)"
:description="formatMessage(messages.deleteConfirmDescription)"
:proceed-label="formatMessage(messages.deleteConfirmButton)"
@proceed="removeApp(editingId)"
/>
<Modal ref="appModal" header="Application information">
<Modal ref="appModal" :header="formatMessage(messages.modalHeader)">
<div class="universal-modal">
<label for="app-name"><span class="label__title">Name</span> </label>
<label for="app-name"
><span class="label__title">{{ formatMessage(messages.nameLabel) }}</span>
</label>
<input
id="app-name"
v-model="name"
maxlength="2048"
type="text"
autocomplete="off"
placeholder="Enter the application's name..."
:placeholder="formatMessage(messages.namePlaceholder)"
/>
<label v-if="editingId" for="app-icon"><span class="label__title">Icon</span> </label>
<label v-if="editingId" for="app-icon"
><span class="label__title">{{ formatMessage(messages.iconLabel) }}</span>
</label>
<div v-if="editingId" class="icon-submission">
<Avatar size="md" :src="icon" />
<FileInput
:max-size="262144"
class="btn"
prompt="Upload icon"
:prompt="formatMessage(messages.uploadIcon)"
accept="image/png,image/jpeg,image/gif,image/webp"
@change="onImageSelection"
>
@@ -32,7 +36,7 @@
</FileInput>
</div>
<label v-if="editingId" for="app-url">
<span class="label__title">URL</span>
<span class="label__title">{{ formatMessage(messages.urlLabel) }}</span>
</label>
<input
v-if="editingId"
@@ -41,10 +45,10 @@
maxlength="255"
type="url"
autocomplete="off"
placeholder="https://example.com"
:placeholder="formatMessage(messages.urlPlaceholder)"
/>
<label v-if="editingId" for="app-description">
<span class="label__title">Description</span>
<span class="label__title">{{ formatMessage(messages.descriptionLabel) }}</span>
</label>
<textarea
v-if="editingId"
@@ -54,19 +58,33 @@
maxlength="255"
type="text"
autocomplete="off"
placeholder="Enter the application's description..."
:placeholder="formatMessage(messages.descriptionPlaceholder)"
/>
<label for="app-scopes"><span class="label__title">Scopes</span> </label>
<div id="app-scopes" class="checkboxes">
<Checkbox
v-for="scope in scopeList"
:key="scope"
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
:model-value="hasScope(scopesVal, scope)"
@update:model-value="() => (scopesVal = toggleScope(scopesVal, scope))"
/>
<label for="app-scopes"
><span class="label__title">{{ formatMessage(messages.scopesLabel) }}</span>
</label>
<div
id="app-scopes"
class="scope-items mt-2 grid grid-cols-1 gap-x-6 gap-y-4 min-[600px]:grid-cols-2"
>
<div v-for="category in scopeCategories" :key="category.name" class="flex flex-col gap-2">
<h4 class="m-0 border-b border-divider pb-1 text-base font-bold text-contrast">
{{ category.name }}
</h4>
<div class="flex flex-col gap-2">
<Checkbox
v-for="scope in category.scopes"
:key="scope"
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
:model-value="hasScope(scopesVal, scope)"
@update:model-value="() => (scopesVal = toggleScope(scopesVal, scope))"
/>
</div>
</div>
</div>
<label for="app-redirect-uris"><span class="label__title">Redirect uris</span> </label>
<label for="app-redirect-uris" class="mt-4"
><span class="label__title">{{ formatMessage(messages.redirectUrisLabel) }}</span>
</label>
<div class="uri-input-list">
<div v-for="(_, index) in redirectUris" :key="index">
<div class="input-group url-input-group-fixes">
@@ -75,7 +93,7 @@
maxlength="2048"
type="url"
autocomplete="off"
placeholder="https://example.com/auth/callback"
:placeholder="formatMessage(messages.redirectUriPlaceholder)"
/>
<Button v-if="index !== 0" icon-only @click="() => redirectUris.splice(index, 1)">
<TrashIcon />
@@ -86,13 +104,13 @@
icon-only
@click="() => redirectUris.push('')"
>
<PlusIcon /> Add more
<PlusIcon /> {{ formatMessage(messages.addMore) }}
</Button>
</div>
</div>
<div v-if="redirectUris.length <= 0">
<Button color="primary" icon-only @click="() => redirectUris.push('')">
<PlusIcon /> Add a redirect uri
<PlusIcon /> {{ formatMessage(messages.addRedirectUri) }}
</Button>
</div>
</div>
@@ -100,7 +118,7 @@
<div class="submit-row input-group push-right">
<button class="iconified-button" @click="$refs.appModal.hide()">
<XIcon />
Cancel
{{ formatMessage(messages.cancel) }}
</button>
<button
v-if="editingId"
@@ -110,7 +128,7 @@
@click="editApp"
>
<SaveIcon />
Save changes
{{ formatMessage(messages.saveChanges) }}
</button>
<button
v-else
@@ -120,7 +138,7 @@
@click="createApp"
>
<PlusIcon />
Create App
{{ formatMessage(messages.createApp) }}
</button>
</div>
</div>
@@ -144,13 +162,17 @@
}
"
>
<PlusIcon /> New Application
<PlusIcon /> {{ formatMessage(messages.newApplication) }}
</button>
</div>
<p>
Applications can be used to authenticate Modrinth's users with your products. For more
information, see
<a class="text-link" href="https://docs.modrinth.com">Modrinth's API documentation</a>.
<IntlFormatted :message-id="messages.descriptionIntro">
<template #docs-link="{ children }">
<a class="text-link" href="https://docs.modrinth.com">
<component :is="() => normalizeChildren(children)" />
</a>
</template>
</IntlFormatted>
</p>
<div v-for="app in usersApps" :key="app.id" class="universal-card recessed token mt-4">
<div class="token-info">
@@ -158,25 +180,31 @@
<Avatar size="sm" :src="app.icon_url" />
<div>
<h2 class="token-title">{{ app.name }}</h2>
<div>Created on {{ new Date(app.created).toLocaleDateString() }}</div>
<div>
{{
formatMessage(messages.createdOn, {
date: new Date(app.created).toLocaleDateString(),
})
}}
</div>
</div>
</div>
<div>
<label for="token-information">
<span class="label__title">About</span>
<span class="label__title">{{ formatMessage(messages.aboutLabel) }}</span>
</label>
<div class="token-content">
<div>
Client ID
{{ formatMessage(messages.clientId) }}
<CopyCode :text="app.id" />
</div>
<div v-if="!!clientCreatedInState(app.id)">
<div>
Client Secret
{{ formatMessage(messages.clientSecret) }}
<CopyCode :text="clientCreatedInState(app.id)?.client_secret" />
</div>
<div class="secret_disclaimer">
<i> Save your secret now, it will be hidden after you leave this page! </i>
<i>{{ formatMessage(messages.secretDisclaimer) }}</i>
</div>
</div>
</div>
@@ -196,7 +224,7 @@
"
>
<EditIcon />
Edit
{{ formatMessage(messages.edit) }}
</Button>
<Button
color="danger"
@@ -209,7 +237,7 @@
"
>
<TrashIcon />
Delete
{{ formatMessage(messages.delete) }}
</Button>
</div>
</div>
@@ -224,8 +252,11 @@ import {
commonSettingsMessages,
ConfirmModal,
CopyCode,
defineMessages,
FileInput,
injectNotificationManager,
IntlFormatted,
normalizeChildren,
useVIntl,
} from '@modrinth/ui'
@@ -233,6 +264,7 @@ import Modal from '~/components/ui/Modal.vue'
import {
getScopeValue,
hasScope,
scopeCategoryMessages,
scopeList,
toggleScope,
useScopes,
@@ -249,8 +281,196 @@ useHead({
title: 'Applications - Modrinth',
})
const messages = defineMessages({
modalHeader: {
id: 'settings.applications.modal.header',
defaultMessage: 'Application information',
},
deleteConfirmTitle: {
id: 'settings.applications.delete.confirm.title',
defaultMessage: 'Are you sure you want to delete this application?',
},
deleteConfirmDescription: {
id: 'settings.applications.delete.confirm.description',
defaultMessage:
'This will permanently delete this application and revoke all access tokens. (forever!)',
},
deleteConfirmButton: {
id: 'settings.applications.delete.confirm.button',
defaultMessage: 'Delete this application',
},
nameLabel: {
id: 'settings.applications.field.name',
defaultMessage: 'Name',
},
namePlaceholder: {
id: 'settings.applications.field.name.placeholder',
defaultMessage: "Enter the application's name...",
},
iconLabel: {
id: 'settings.applications.field.icon',
defaultMessage: 'Icon',
},
uploadIcon: {
id: 'settings.applications.button.upload-icon',
defaultMessage: 'Upload icon',
},
urlLabel: {
id: 'settings.applications.field.url',
defaultMessage: 'URL',
},
urlPlaceholder: {
id: 'settings.applications.field.url.placeholder',
defaultMessage: 'https://example.com',
},
descriptionLabel: {
id: 'settings.applications.field.description',
defaultMessage: 'Description',
},
descriptionPlaceholder: {
id: 'settings.applications.field.description.placeholder',
defaultMessage: "Enter the application's description...",
},
scopesLabel: {
id: 'settings.applications.field.scopes',
defaultMessage: 'Scopes',
},
redirectUrisLabel: {
id: 'settings.applications.field.redirect-uris',
defaultMessage: 'Redirect uris',
},
redirectUriPlaceholder: {
id: 'settings.applications.field.redirect-uri.placeholder',
defaultMessage: 'https://example.com/auth/callback',
},
addMore: {
id: 'settings.applications.button.add-more',
defaultMessage: 'Add more',
},
addRedirectUri: {
id: 'settings.applications.button.add-redirect-uri',
defaultMessage: 'Add a redirect uri',
},
cancel: {
id: 'settings.applications.button.cancel',
defaultMessage: 'Cancel',
},
saveChanges: {
id: 'settings.applications.button.save-changes',
defaultMessage: 'Save changes',
},
createApp: {
id: 'settings.applications.button.create',
defaultMessage: 'Create app',
},
newApplication: {
id: 'settings.applications.button.new',
defaultMessage: 'New application',
},
descriptionIntro: {
id: 'settings.applications.description.intro',
defaultMessage:
"Applications can be used to authenticate Modrinth's users with your products. For more information, see <docs-link>Modrinth's API documentation</docs-link>.",
},
aboutLabel: {
id: 'settings.applications.about',
defaultMessage: 'About',
},
clientId: {
id: 'settings.applications.client-id',
defaultMessage: 'Client ID',
},
clientSecret: {
id: 'settings.applications.client-secret',
defaultMessage: 'Client secret',
},
secretDisclaimer: {
id: 'settings.applications.secret.disclaimer',
defaultMessage: 'Save your secret now, it will be hidden after you leave this page!',
},
createdOn: {
id: 'settings.applications.created-on',
defaultMessage: 'Created on {date}',
},
edit: {
id: 'settings.applications.button.edit',
defaultMessage: 'Edit',
},
delete: {
id: 'settings.applications.button.delete',
defaultMessage: 'Delete',
},
iconUpdatedTitle: {
id: 'settings.applications.notification.icon-updated.title',
defaultMessage: 'Icon updated',
},
iconUpdatedDescription: {
id: 'settings.applications.notification.icon-updated.description',
defaultMessage: 'Your application icon has been updated.',
},
errorTitle: {
id: 'settings.applications.notification.error.title',
defaultMessage: 'An error occurred',
},
})
const { scopesToLabels } = useScopes()
const scopeCategories = computed(() => {
return [
{
name: formatMessage(scopeCategoryMessages.categoryUserAccount),
scopes: scopeList.filter((s) => s.startsWith('USER_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryProjects),
scopes: scopeList.filter((s) => s.startsWith('PROJECT_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryVersions),
scopes: scopeList.filter((s) => s.startsWith('VERSION_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryCollections),
scopes: scopeList.filter((s) => s.startsWith('COLLECTION_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryOrganizations),
scopes: scopeList.filter((s) => s.startsWith('ORGANIZATION_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryReports),
scopes: scopeList.filter((s) => s.startsWith('REPORT_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryThreads),
scopes: scopeList.filter((s) => s.startsWith('THREAD_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryPats),
scopes: scopeList.filter((s) => s.startsWith('PAT_')),
},
{
name: formatMessage(scopeCategoryMessages.categorySessions),
scopes: scopeList.filter((s) => s.startsWith('SESSION_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryNotifications),
scopes: scopeList.filter((s) => s.startsWith('NOTIFICATION_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryPayouts),
scopes: scopeList.filter((s) => s.startsWith('PAYOUTS_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryAnalytics),
scopes: scopeList.filter(
(s) => s.startsWith('ANALYTICS') || s.startsWith('PERFORM_ANALYTICS'),
),
},
].filter((c) => c.scopes.length > 0)
})
const appModal = ref()
// Any apps created in the current state will be stored here
@@ -347,8 +567,8 @@ async function onImageSelection(files) {
}
addNotification({
title: 'Icon updated',
text: 'Your application icon has been updated.',
title: formatMessage(messages.iconUpdatedTitle),
text: formatMessage(messages.iconUpdatedDescription),
type: 'success',
})
}
@@ -377,7 +597,7 @@ async function createApp() {
await refresh()
} catch (err) {
addNotification({
title: 'An error occurred',
title: formatMessage(messages.errorTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
@@ -445,7 +665,7 @@ async function editApp() {
appModal.value.hide()
} catch (err) {
addNotification({
title: 'An error occurred',
title: formatMessage(messages.errorTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
@@ -468,7 +688,7 @@ async function removeApp() {
editingId.value = null
} catch (err) {
addNotification({
title: 'An error occurred',
title: formatMessage(messages.errorTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
@@ -500,17 +720,10 @@ async function removeApp() {
flex-basis: 24rem !important;
}
}
.checkboxes {
display: grid;
column-gap: 0.5rem;
@media screen and (min-width: 432px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 800px) {
grid-template-columns: repeat(3, 1fr);
}
.scope-items :deep(.checkbox-outer) {
white-space: nowrap !important;
justify-content: flex-start !important;
}
.icon-submission {
+79 -25
View File
@@ -29,16 +29,26 @@
<label for="pat-scopes">
<span class="label__title">{{ formatMessage(commonMessages.scopesLabel) }}</span>
</label>
<div id="pat-scopes" class="checkboxes">
<Checkbox
v-for="scope in scopeList"
:key="scope"
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
:model-value="hasScope(scopesVal, scope)"
@update:model-value="scopesVal = toggleScope(scopesVal, scope)"
/>
<div
id="pat-scopes"
class="scope-items mt-2 grid grid-cols-1 gap-x-6 gap-y-4 min-[600px]:grid-cols-2"
>
<div v-for="category in scopeCategories" :key="category.name" class="flex flex-col gap-2">
<h4 class="m-0 border-b border-divider pb-1 text-base font-bold text-contrast">
{{ category.name }}
</h4>
<div class="flex flex-col gap-2">
<Checkbox
v-for="scope in category.scopes"
:key="scope"
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
:model-value="hasScope(scopesVal, scope)"
@update:model-value="scopesVal = toggleScope(scopesVal, scope)"
/>
</div>
</div>
</div>
<label for="pat-name">
<label for="pat-name" class="mt-4">
<span class="label__title">{{ formatMessage(createModalMessages.expiresLabel) }}</span>
</label>
<input id="pat-name" v-model="expires" type="date" />
@@ -171,7 +181,7 @@
</template>
</div>
</div>
<div class="input-group">
<div class="token-actions ml-auto flex flex-col gap-2">
<button
class="iconified-button raised-button"
@click="
@@ -220,6 +230,7 @@ import Modal from '~/components/ui/Modal.vue'
import {
getScopeValue,
hasScope,
scopeCategoryMessages,
scopeList,
toggleScope,
useScopes,
@@ -338,6 +349,61 @@ const displayPats = computed(() => {
return pats.value.toSorted((a, b) => new Date(b.created) - new Date(a.created))
})
const scopeCategories = computed(() => {
return [
{
name: formatMessage(scopeCategoryMessages.categoryUserAccount),
scopes: scopeList.filter((s) => s.startsWith('USER_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryProjects),
scopes: scopeList.filter((s) => s.startsWith('PROJECT_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryVersions),
scopes: scopeList.filter((s) => s.startsWith('VERSION_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryCollections),
scopes: scopeList.filter((s) => s.startsWith('COLLECTION_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryOrganizations),
scopes: scopeList.filter((s) => s.startsWith('ORGANIZATION_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryReports),
scopes: scopeList.filter((s) => s.startsWith('REPORT_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryThreads),
scopes: scopeList.filter((s) => s.startsWith('THREAD_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryPats),
scopes: scopeList.filter((s) => s.startsWith('PAT_')),
},
{
name: formatMessage(scopeCategoryMessages.categorySessions),
scopes: scopeList.filter((s) => s.startsWith('SESSION_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryNotifications),
scopes: scopeList.filter((s) => s.startsWith('NOTIFICATION_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryPayouts),
scopes: scopeList.filter((s) => s.startsWith('PAYOUTS_')),
},
{
name: formatMessage(scopeCategoryMessages.categoryAnalytics),
scopes: scopeList.filter(
(s) => s.startsWith('ANALYTICS') || s.startsWith('PERFORM_ANALYTICS'),
),
},
].filter((c) => c.scopes.length > 0)
})
async function createPat() {
startLoading()
loading.value = true
@@ -407,17 +473,9 @@ async function removePat(id) {
}
</script>
<style lang="scss" scoped>
.checkboxes {
display: grid;
column-gap: 0.5rem;
@media screen and (min-width: 432px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 800px) {
grid-template-columns: repeat(3, 1fr);
}
.scope-items :deep(.checkbox-outer) {
white-space: nowrap !important;
justify-content: flex-start !important;
}
.token {
@@ -428,10 +486,6 @@ async function removePat(id) {
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: center;
.input-group {
margin-left: auto;
}
}
}
</style>
@@ -651,15 +651,27 @@ export function createWithdrawContext(
)
if (selectedMethod?.interval) {
const userMax = Math.floor(maxWithdrawAmount.value * 100) / 100
const userMaxUsd = Math.floor(maxWithdrawAmount.value * 100) / 100
const exchangeRate = selectedMethod.exchange_rate
const isNonUsdCurrency =
selectedMethod.currency_code &&
selectedMethod.currency_code !== 'USD' &&
exchangeRate &&
exchangeRate > 0
const userMaxInLocalCurrency = isNonUsdCurrency ? userMaxUsd * exchangeRate : userMaxUsd
if (selectedMethod.interval.standard) {
const { min, max } = selectedMethod.interval.standard
const effectiveMax = Math.min(userMax, max)
const effectiveMax = Math.min(userMaxInLocalCurrency, max)
const effectiveMin = Math.min(min, effectiveMax)
if (amount < effectiveMin || amount > effectiveMax) return false
}
if (selectedMethod.interval.fixed) {
const validValues = selectedMethod.interval.fixed.values.filter((v) => v <= userMax)
const validValues = selectedMethod.interval.fixed.values.filter(
(v) => v <= userMaxInLocalCurrency,
)
if (!validValues.includes(amount)) return false
}
}
@@ -817,6 +829,7 @@ export function createWithdrawContext(
calculation: {
amount: 0,
fee: null,
netUsd: null,
exchangeRate: null,
},
providerData: {
+5
View File
@@ -289,12 +289,15 @@ video.unrulymedia.com, 586616193, RESELLER
#PGAMSSP
pgamssp.com, 642db2eba4eb3f6b34033505, DIRECT
pubmatic.com, 162623, RESELLER, 5d62403b186f2ace
pubmatic.com, 165708, RESELLER, 5d62403b186f2ace
rubiconproject.com, 24852, RESELLER, 0bfd66d529a55807
video.unrulymedia.com, 5921144960123684292, RESELLER
onetag.com, 87f80e5d9d55274, RESELLER
amxrtb.com, 105199776, RESELLER
sharethrough.com, FhiWXM0L, RESELLER, d53b998a7bd4ecd2
sharethrough.com, XeKuhSkz, RESELLER, d53b998a7bd4ecd2
33across.com, 0015a00003ALsDfAAL, DIRECT, bbea06d9c4d2853c
33across.com, 0015a00002egvRSAAY, RESELLER, bbea06d9c4d2853c
admanmedia.com, 2209, RESELLER
triplelift.com, 12456, RESELLER, 6c33edb13117fd86
media.net, 8CU58PCO4, RESELLER
@@ -302,6 +305,8 @@ lijit.com, 402418, RESELLER, fafdf38b16bf6b2b
inmobi.com, 8b70afb78ac249ccbd71be4f75dbf09c, RESELLER, 83e75a7ae333ca9d
smaato.com, 1100058906, RESELLER, 07bcf65f187117b4
appnexus.com, 15427 , RESELLER, f5ab79cb980f11d1
appnexus.com, 9684, RESELLER, f5ab79cb980f11d1
lijit.com, 402418, RESELLER, fafdf38b16bf6b2b
#Pubmatic
pubmatic.com, 160923, RESELLER, 5d62403b186f2ace
+1
View File
@@ -19,6 +19,7 @@ MEILISEARCH_WRITE_ADDRS=http://localhost:7700
MEILISEARCH_KEY=modrinth
REDIS_URL=redis://labrinth-redis
REDIS_MIN_CONNECTIONS=0
REDIS_MAX_CONNECTIONS=10000
BIND_ADDR=0.0.0.0:8000
+3
View File
@@ -17,6 +17,8 @@ DATABASE_MAX_CONNECTIONS=16
MEILISEARCH_READ_ADDR=http://localhost:7700
MEILISEARCH_WRITE_ADDRS=http://localhost:7700
# 5 minutes in milliseconds
SEARCH_OPERATION_TIMEOUT=300000
# # For a sharded Meilisearch setup (sharded-meilisearch docker compose profile)
# MEILISEARCH_READ_ADDR=http://localhost:7710
@@ -25,6 +27,7 @@ MEILISEARCH_WRITE_ADDRS=http://localhost:7700
MEILISEARCH_KEY=modrinth
REDIS_URL=redis://localhost
REDIS_MIN_CONNECTIONS=0
REDIS_MAX_CONNECTIONS=10000
# Must bind to broadcast, not localhost, because some
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1))\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "f8be3053274b00ee9743e798886696062009c5f681baaf29dfc24cfbbda93742"
}
+35 -9
View File
@@ -3,6 +3,7 @@ use ariadne::ids::base62_impl::{parse_base62, to_base62};
use chrono::{TimeZone, Utc};
use dashmap::DashMap;
use deadpool_redis::{Config, Runtime};
use futures::TryStreamExt;
use futures::future::Either;
use futures::stream::{FuturesUnordered, StreamExt};
use prometheus::{IntGauge, Registry};
@@ -13,8 +14,9 @@ use std::collections::HashMap;
use std::fmt::{Debug, Display};
use std::future::Future;
use std::hash::Hash;
use std::sync::Arc;
use std::time::Duration;
use tracing::{Instrument, info_span};
use tracing::{Instrument, info, info_span};
use util::{cmd, redis_pipe};
pub mod util;
@@ -25,21 +27,21 @@ const ACTUAL_EXPIRY: i64 = 60 * 30; // 30 minutes
#[derive(Clone)]
pub struct RedisPool {
pub url: String,
pub pool: util::InstrumentedPool,
cache_list: DashMap<String, util::CacheSubscriber>,
meta_namespace: String,
pub pool: deadpool_redis::Pool,
cache_list: Arc<DashMap<String, util::CacheSubscriber>>,
meta_namespace: Arc<str>,
}
pub struct RedisConnection {
pub connection: deadpool_redis::Connection,
meta_namespace: String,
meta_namespace: Arc<str>,
}
impl RedisPool {
// initiate a new redis pool
// testing pool uses a hashmap to mimic redis behaviour for very small data sizes (ie: tests)
// PANICS: production pool will panic if redis url is not set
pub fn new(meta_namespace: Option<String>) -> Self {
pub fn new(meta_namespace: impl Into<Arc<str>>) -> Self {
let wait_timeout =
dotenvy::var("REDIS_WAIT_TIMEOUT_MS").ok().map_or_else(
|| Duration::from_millis(15000),
@@ -69,11 +71,35 @@ impl RedisPool {
let pool = RedisPool {
url,
pool: util::InstrumentedPool::new(pool),
cache_list: DashMap::with_capacity(2048),
meta_namespace: meta_namespace.unwrap_or("".to_string()),
pool,
cache_list: Arc::new(DashMap::with_capacity(2048)),
meta_namespace: meta_namespace.into(),
};
let redis_min_connections = dotenvy::var("REDIS_MIN_CONNECTIONS")
.ok()
.and_then(|x| x.parse::<usize>().ok())
.unwrap_or(0);
let spawn_min_connections = (0..redis_min_connections)
.map(|_| {
let pool = pool.clone();
tokio::spawn(async move { pool.pool.get().await })
})
.collect::<FuturesUnordered<_>>();
tokio::spawn({
let pool = pool.clone();
async move {
// collect the connections into a buffer while we're spawning them,
// to make sure that we're not `get`ing any connections we previously took
let _connections =
spawn_min_connections.try_collect::<Vec<_>>().await;
info!(
pool_status = ?pool.pool.status(),
"Finished getting {redis_min_connections} initial Redis connections"
);
}
});
let interval = Duration::from_secs(30);
let max_age = Duration::from_secs(5 * 60); // 5 minutes
let pool_ref = pool.clone();
+2 -21
View File
@@ -2,7 +2,6 @@ use std::fmt::Debug;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use deadpool_redis::PoolError;
use derive_more::{Deref, DerefMut};
use redis::{FromRedisValue, RedisResult, ToRedisArgs};
use tokio::sync::Notify;
@@ -11,24 +10,6 @@ use tracing::{Instrument, info_span};
use crate::database::models::DatabaseError;
#[derive(Debug, Clone, Deref, DerefMut)]
pub struct InstrumentedPool {
inner: deadpool_redis::Pool,
}
impl InstrumentedPool {
pub fn new(inner: deadpool_redis::Pool) -> Self {
Self { inner }
}
pub async fn get(&self) -> Result<deadpool_redis::Connection, PoolError> {
self.inner
.get()
.instrument(info_span!("get redis connection"))
.await
}
}
pub fn redis_pipe() -> InstrumentedPipeline {
InstrumentedPipeline {
inner: redis::pipe(),
@@ -55,7 +36,7 @@ impl InstrumentedPipeline {
) -> RedisResult<T> {
self.inner
.query_async(con)
.instrument(info_span!("execute redis pipeline"))
.instrument(info_span!("pipeline.query_async"))
.await
}
}
@@ -88,7 +69,7 @@ impl InstrumentedCmd {
con: &mut impl redis::aio::ConnectionLike,
) -> RedisResult<T> {
let span = info_span!(
"query_async",
"cmd.query_async",
// <https://opentelemetry.io/docs/specs/semconv/db/redis/>
db.system.name = "redis",
db.operation.name = self.name,
+12 -20
View File
@@ -18,7 +18,6 @@ use crate::background_task::update_versions;
use crate::database::{PgPool, ReadOnlyPgPool};
use crate::queue::billing::{index_billing, index_subscriptions};
use crate::queue::moderation::AutomatedModerationQueue;
use crate::search::MeilisearchReadClient;
use crate::util::anrok;
use crate::util::archon::ArchonClient;
use crate::util::env::{parse_strings_from_var, parse_var};
@@ -68,7 +67,6 @@ pub struct LabrinthConfig {
pub email_queue: web::Data<EmailQueue>,
pub archon_client: web::Data<ArchonClient>,
pub gotenberg_client: GotenbergClient,
pub search_read_client: web::Data<MeilisearchReadClient>,
}
#[allow(clippy::too_many_arguments)]
@@ -275,11 +273,6 @@ pub fn app_setup(
file_host,
scheduler: Arc::new(scheduler),
ip_salt,
search_read_client: web::Data::new(
search_config.make_loadbalanced_read_client().expect(
"Failed to make Meilisearch client for read operations",
),
),
search_config,
session_queue,
payouts_queue: web::Data::new(PayoutsQueue::new()),
@@ -331,18 +324,7 @@ pub fn app_config(
.app_data(labrinth_config.archon_client.clone())
.app_data(web::Data::new(labrinth_config.stripe_client.clone()))
.app_data(web::Data::new(labrinth_config.anrok_client.clone()))
.app_data(labrinth_config.search_read_client.clone())
.app_data(labrinth_config.rate_limiter.clone())
.configure({
#[cfg(target_os = "linux")]
{
|cfg| routes::debug::config(cfg)
}
#[cfg(not(target_os = "linux"))]
{
|_cfg| ()
}
})
.configure(routes::v2::config)
.configure(routes::v3::config)
.configure(routes::internal::config)
@@ -354,8 +336,18 @@ pub fn utoipa_app_config(
cfg: &mut utoipa_actix_web::service_config::ServiceConfig,
_labrinth_config: LabrinthConfig,
) {
cfg.configure(routes::v3::utoipa_config)
.configure(routes::internal::utoipa_config);
cfg.configure({
#[cfg(target_os = "linux")]
{
|cfg| routes::debug::config(cfg)
}
#[cfg(not(target_os = "linux"))]
{
|_cfg| ()
}
})
.configure(routes::v3::utoipa_config)
.configure(routes::internal::utoipa_config);
}
// This is so that env vars not used immediately don't panic at runtime
+3 -4
View File
@@ -117,7 +117,7 @@ async fn app() -> std::io::Result<()> {
.expect("Database connection failed");
// Redis connector
let redis_pool = RedisPool::new(None);
let redis_pool = RedisPool::new("");
let storage_backend =
dotenvy::var("STORAGE_BACKEND").unwrap_or_else(|_| "local".to_string());
@@ -206,9 +206,8 @@ async fn app() -> std::io::Result<()> {
.await
.expect("Failed to register redis metrics");
#[cfg(target_os = "linux")]
labrinth::routes::debug::jemalloc_memory_stats(&prometheus.registry)
.expect("Failed to register jemalloc metrics");
labrinth::routes::debug::register_and_set_metrics(&prometheus.registry)
.expect("Failed to register debug metrics");
let labrinth_config = labrinth::app_setup(
pool.clone(),
+53 -65
View File
@@ -1,83 +1,71 @@
use crate::routes::ApiError;
use crate::util::cors::default_cors;
use crate::util::guards::admin_key_guard;
use actix_web::{HttpResponse, get};
use prometheus::{IntGauge, Registry};
use std::time::Duration;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
use eyre::Context;
use eyre::eyre;
use prometheus::IntGauge;
use crate::util::cors::default_cors;
#[cfg(target_os = "linux")]
mod pprof;
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(
actix_web::web::scope("/debug")
utoipa_actix_web::scope("/debug")
.wrap(default_cors())
.service(heap)
.service(flame_graph),
.configure({
#[cfg(target_os = "linux")]
{
pprof::config
}
#[cfg(not(target_os = "linux"))]
{
|_cfg| ()
}
}),
);
}
#[get("pprof/heap", guard = "admin_key_guard")]
pub async fn heap() -> Result<HttpResponse, ApiError> {
let mut prof_ctl = jemalloc_pprof::PROF_CTL.as_ref().unwrap().lock().await;
require_profiling_activated(&prof_ctl)?;
let pprof = prof_ctl
.dump_pprof()
.map_err(|err| ApiError::InvalidInput(err.to_string()))?;
Ok(HttpResponse::Ok()
.content_type("application/octet-stream")
.body(pprof))
}
#[get("pprof/heap/flamegraph", guard = "admin_key_guard")]
pub async fn flame_graph() -> Result<HttpResponse, ApiError> {
let mut prof_ctl = jemalloc_pprof::PROF_CTL.as_ref().unwrap().lock().await;
require_profiling_activated(&prof_ctl)?;
let svg = prof_ctl
.dump_flamegraph()
.map_err(|err| ApiError::InvalidInput(err.to_string()))?;
Ok(HttpResponse::Ok().content_type("image/svg+xml").body(svg))
}
fn require_profiling_activated(
prof_ctl: &jemalloc_pprof::JemallocProfCtl,
) -> Result<(), ApiError> {
if prof_ctl.activated() {
Ok(())
} else {
Err(ApiError::InvalidInput(
"Profiling is not activated".to_string(),
))
pub fn register_and_set_metrics(
registry: &prometheus::Registry,
) -> eyre::Result<()> {
#[cfg(target_os = "linux")]
{
pprof::register_and_set_metrics(registry)
.wrap_err("failed to register jemalloc metrics")?;
}
}
pub fn jemalloc_memory_stats(
registry: &Registry,
) -> Result<(), prometheus::Error> {
let allocated_mem = IntGauge::new(
"labrinth_memory_allocated",
"labrinth allocated memory",
let make_gauge = |key: &str, name: &str| {
IntGauge::new(key, name)
.wrap_err_with(|| eyre!("failed to create gauge for '{key}'"))
};
let num_workers = make_gauge(
"labrinth_tokio_num_workers",
"number of Tokio worker threads, excluding Actix HTTP server threads",
)?;
let num_alive_tasks = make_gauge(
"labrinth_tokio_num_alive_tasks",
"number of alive Tokio tasks, excluding Actix HTTP server tasks",
)?;
let global_queue_depth = make_gauge(
"labrinth_tokio_global_queue_depth",
"number of tasks in the global queue, excluding Actix runtime",
)?;
let resident_mem =
IntGauge::new("labrinth_resident_memory", "labrinth resident memory")?;
registry.register(Box::new(allocated_mem.clone()))?;
registry.register(Box::new(resident_mem.clone()))?;
for gauge in [&num_workers, &num_alive_tasks, &global_queue_depth] {
registry
.register(Box::new(gauge.clone()))
.wrap_err("failed to register gauge")?;
}
tokio::spawn(async move {
let e = tikv_jemalloc_ctl::epoch::mib().unwrap();
let allocated = tikv_jemalloc_ctl::stats::allocated::mib().unwrap();
let resident = tikv_jemalloc_ctl::stats::resident::mib().unwrap();
let metrics = tokio::runtime::Handle::current().metrics();
loop {
e.advance().unwrap();
if let Ok(allocated) = allocated.read() {
allocated_mem.set(allocated as i64);
}
if let Ok(resident) = resident.read() {
resident_mem.set(resident as i64);
}
num_workers.set(metrics.num_workers() as i64);
num_alive_tasks.set(metrics.num_alive_tasks() as i64);
global_queue_depth.set(metrics.global_queue_depth() as i64);
tokio::time::sleep(Duration::from_secs(5)).await;
}
+102
View File
@@ -0,0 +1,102 @@
use crate::routes::ApiError;
use crate::util::guards::admin_key_guard;
use actix_web::{HttpResponse, get};
use eyre::{Context, eyre};
use prometheus::{IntGauge, Registry};
use std::time::Duration;
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(heap).service(flame_graph);
}
#[utoipa::path]
#[get("/pprof/heap", guard = "admin_key_guard")]
pub async fn heap() -> Result<HttpResponse, ApiError> {
let mut prof_ctl = jemalloc_pprof::PROF_CTL.as_ref().unwrap().lock().await;
require_profiling_activated(&prof_ctl)?;
let pprof = prof_ctl
.dump_pprof()
.map_err(|err| ApiError::InvalidInput(err.to_string()))?;
Ok(HttpResponse::Ok()
.content_type("application/octet-stream")
.body(pprof))
}
#[utoipa::path]
#[get("/pprof/heap/flamegraph", guard = "admin_key_guard")]
pub async fn flame_graph() -> Result<HttpResponse, ApiError> {
let mut prof_ctl = jemalloc_pprof::PROF_CTL.as_ref().unwrap().lock().await;
require_profiling_activated(&prof_ctl)?;
let svg = prof_ctl
.dump_flamegraph()
.map_err(|err| ApiError::InvalidInput(err.to_string()))?;
Ok(HttpResponse::Ok().content_type("image/svg+xml").body(svg))
}
fn require_profiling_activated(
prof_ctl: &jemalloc_pprof::JemallocProfCtl,
) -> Result<(), ApiError> {
if prof_ctl.activated() {
Ok(())
} else {
Err(ApiError::InvalidInput(
"Profiling is not activated".to_string(),
))
}
}
pub fn register_and_set_metrics(registry: &Registry) -> eyre::Result<()> {
let make_gauge = |key: &str, name: &str| {
IntGauge::new(key, name)
.wrap_err_with(|| eyre!("failed to create gauge for '{key}'"))
};
let active_mem =
make_gauge("labrinth_memory_active", "labrinth active memory")?;
let allocated_mem =
make_gauge("labrinth_memory_allocated", "labrinth allocated memory")?;
let mapped_mem =
make_gauge("labrinth_memory_mapped", "labrinth mapped memory")?;
let metadata_mem =
make_gauge("labrinth_memory_metadata", "labrinth metadata memory")?;
let resident_mem =
make_gauge("labrinth_memory_resident", "labrinth resident memory")?;
for gauge in [
&active_mem,
&allocated_mem,
&mapped_mem,
&metadata_mem,
&resident_mem,
] {
registry
.register(Box::new(gauge.clone()))
.wrap_err("failed to register gauge")?;
}
tokio::spawn(async move {
let epoch =
tikv_jemalloc_ctl::epoch::mib().expect("failed to get epoch");
let active = tikv_jemalloc_ctl::stats::active::mib().unwrap();
let allocated = tikv_jemalloc_ctl::stats::allocated::mib().unwrap();
let mapped = tikv_jemalloc_ctl::stats::mapped::mib().unwrap();
let metadata = tikv_jemalloc_ctl::stats::metadata::mib().unwrap();
let resident = tikv_jemalloc_ctl::stats::resident::mib().unwrap();
loop {
epoch.advance().unwrap();
_ = active.read().inspect(|x| active_mem.set(*x as i64));
_ = allocated.read().inspect(|x| allocated_mem.set(*x as i64));
_ = mapped.read().inspect(|x| mapped_mem.set(*x as i64));
_ = metadata.read().inspect(|x| metadata_mem.set(*x as i64));
_ = resident.read().inspect(|x| resident_mem.set(*x as i64));
tokio::time::sleep(Duration::from_secs(5)).await;
}
});
Ok(())
}
@@ -0,0 +1,39 @@
use std::{collections::HashMap, sync::LazyLock};
use actix_web::{get, web};
use serde::{Deserialize, Serialize};
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(get_globals);
}
/// See [`get`].
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct Globals {
/// Map of years to how much a creator can withdraw in that year, in USD,
/// before they must fill in a tax compliance form.
///
/// If the current year is not contained in this map:
/// - if the year is before the first year in the map, the threshold is the first year's.
/// - if the year is after the last year in the map, the threshold is the last year's threshold.
pub tax_compliance_thresholds: HashMap<u16, u64>,
/// If this backend instance has a Captcha enabled for password login.
///
/// In production, this will always be true. On local testing builds, this
/// will always be false.
pub captcha_enabled: bool,
}
static GLOBALS: LazyLock<Globals> = LazyLock::new(|| Globals {
tax_compliance_thresholds: [(2025, 600), (2026, 2000)]
.into_iter()
.collect(),
captcha_enabled: dotenvy::var("HCAPTCHA_SECRET").is_ok_and(|x| x != "none"),
});
/// Gets configured global non-secret variables for this backend instance.
#[utoipa::path]
#[get("")]
pub async fn get_globals() -> web::Json<Globals> {
web::Json(GLOBALS.clone())
}
+6
View File
@@ -5,6 +5,7 @@ pub mod delphi;
pub mod external_notifications;
pub mod flows;
pub mod gdpr;
pub mod globals;
pub mod gotenberg;
pub mod medal;
pub mod moderation;
@@ -55,5 +56,10 @@ pub fn utoipa_config(
utoipa_actix_web::scope("/_internal/search-management")
.wrap(default_cors())
.configure(search::config),
)
.service(
utoipa_actix_web::scope("/_internal/globals")
.wrap(default_cors())
.configure(globals::config),
);
}
+2 -5
View File
@@ -10,14 +10,11 @@ use actix_web::{HttpResponse, web};
use futures::FutureExt;
use serde_json::json;
pub mod debug;
pub mod internal;
pub mod v2;
pub mod v3;
#[cfg(target_os = "linux")]
pub mod debug;
pub mod v2_reroute;
pub mod v3;
mod analytics;
mod index;
+2 -5
View File
@@ -14,9 +14,7 @@ use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
use crate::routes::v3::projects::ProjectIds;
use crate::routes::{ApiError, v2_reroute, v3};
use crate::search::{
MeilisearchReadClient, SearchConfig, SearchError, search_for_project,
};
use crate::search::{SearchConfig, SearchError, search_for_project};
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -56,7 +54,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
pub async fn project_search(
web::Query(info): web::Query<SearchRequest>,
config: web::Data<SearchConfig>,
read_client: web::Data<MeilisearchReadClient>,
) -> Result<HttpResponse, SearchError> {
// Search now uses loader_fields instead of explicit 'client_side' and 'server_side' fields
// While the backend for this has changed, it doesnt affect much
@@ -102,7 +99,7 @@ pub async fn project_search(
..info
};
let results = search_for_project(&info, &config, &read_client).await?;
let results = search_for_project(&info, &config).await?;
let results = LegacySearchResults::from(results);
+6 -2
View File
@@ -498,9 +498,13 @@ pub async fn create_payout(
let balance = get_user_balance(user.id, &pool)
.await
.wrap_internal_err("failed to calculate user balance")?;
if balance.available < body.amount || body.amount < Decimal::ZERO {
// Note: We only check for negative amounts here. The full balance validation
// happens later in payout_flow.validate() which correctly handles currency
// conversion (body.amount may be in local currency for gift cards, not USD).
if body.amount < Decimal::ZERO {
return Err(ApiError::InvalidInput(
"You do not have enough funds to make this payout!".to_string(),
"Amount must be positive!".to_string(),
));
}
@@ -497,7 +497,12 @@ async fn project_create_inner(
{
let results = sqlx::query!(
"
SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1))
SELECT EXISTS(
SELECT 1 FROM mods
WHERE
slug = LOWER($1)
OR text_id_lower = LOWER($1)
)
",
create_data.slug
)
+2 -5
View File
@@ -28,9 +28,7 @@ use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::search::indexing::remove_documents;
use crate::search::{
MeilisearchReadClient, SearchConfig, SearchError, search_for_project,
};
use crate::search::{SearchConfig, SearchError, search_for_project};
use crate::util::img;
use crate::util::img::{delete_old_images, upload_image_optimized};
use crate::util::routes::read_limited_from_payload;
@@ -1039,9 +1037,8 @@ pub async fn edit_project_categories(
pub async fn project_search(
web::Query(info): web::Query<SearchRequest>,
config: web::Data<SearchConfig>,
read_client: web::Data<MeilisearchReadClient>,
) -> Result<HttpResponse, SearchError> {
let results = search_for_project(&info, &config, &read_client).await?;
let results = search_for_project(&info, &config).await?;
// TODO: add this back
// let results = ReturnSearchResults {
+5 -4
View File
@@ -14,6 +14,7 @@ use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use actix_web::{HttpRequest, HttpResponse, web};
use ariadne::ids::UserId;
use eyre::eyre;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
@@ -708,10 +709,10 @@ pub async fn edit_team_member(
DBTeamMember::get_from_user_id_pending(id, user_id, &**pool)
.await?
.ok_or_else(|| {
ApiError::CustomAuthentication(
"You don't have permission to edit members of this team"
.to_string(),
)
ApiError::Request(eyre!(
"This member does not exist in this team - \
the member must first be created via `POST`"
))
})?;
let mut transaction = pool.begin().await?;
+64 -28
View File
@@ -39,14 +39,22 @@ pub enum IndexingError {
//
// Set this to 50k for better observability
const MEILISEARCH_CHUNK_SIZE: usize = 50000; // 10_000_000
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
fn search_operation_timeout() -> std::time::Duration {
let default_ms = 5 * 60 * 1000; // 5 minutes
let ms = dotenvy::var("SEARCH_OPERATION_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(default_ms);
std::time::Duration::from_millis(ms)
}
pub async fn remove_documents(
ids: &[crate::models::ids::VersionId],
config: &SearchConfig,
) -> Result<(), IndexingError> {
let mut indexes = get_indexes_for_indexing(config, false).await?;
let indexes_next = get_indexes_for_indexing(config, true).await?;
let mut indexes = get_indexes_for_indexing(config, false, false).await?;
let indexes_next = get_indexes_for_indexing(config, true, false).await?;
for list in &mut indexes {
for alt_list in &indexes_next {
@@ -94,11 +102,11 @@ pub async fn index_projects(
info!("Ensuring current indexes exists");
// First, ensure current index exists (so no error happens- current index should be worst-case empty, not missing)
get_indexes_for_indexing(config, false).await?;
get_indexes_for_indexing(config, false, false).await?;
info!("Deleting surplus indexes");
// Then, delete the next index if it still exists
let indices = get_indexes_for_indexing(config, true).await?;
let indices = get_indexes_for_indexing(config, true, false).await?;
for client_indices in indices {
for index in client_indices {
index.delete().await?;
@@ -107,7 +115,7 @@ pub async fn index_projects(
info!("Recreating next index");
// Recreate the next index for indexing
let indices = get_indexes_for_indexing(config, true).await?;
let indices = get_indexes_for_indexing(config, true, true).await?;
let all_loader_fields =
crate::database::models::loader_fields::LoaderField::get_fields_all(
@@ -208,6 +216,7 @@ pub async fn swap_index(
pub async fn get_indexes_for_indexing(
config: &SearchConfig,
next: bool, // Get the 'next' one
update_settings: bool,
) -> Result<Vec<Vec<Index>>, IndexingError> {
let client = config.make_batch_client()?;
let project_name = config.get_index_name("projects", next);
@@ -230,6 +239,7 @@ pub async fn get_indexes_for_indexing(
"exactness",
"sort",
]),
update_settings,
)
.await?;
let projects_filtered_index = create_or_update_index(
@@ -243,6 +253,7 @@ pub async fn get_indexes_for_indexing(
"attribute",
"exactness",
]),
update_settings,
)
.await?;
@@ -258,6 +269,7 @@ async fn create_or_update_index(
client: &Client,
name: &str,
custom_rules: Option<&'static [&'static str]>,
update_settings: bool,
) -> Result<Index, meilisearch_sdk::errors::Error> {
info!("Updating/creating index");
@@ -271,16 +283,26 @@ async fn create_or_update_index(
settings = settings.with_ranking_rules(custom_rules);
}
info!("Performing index settings set.");
index
.set_settings(&settings)
.await
.inspect_err(|e| error!("Error setting index settings: {e:?}"))?
.wait_for_completion(client, None, Some(TIMEOUT))
.await
.inspect_err(|e| {
error!("Error setting index settings while waiting: {e:?}")
})?;
if update_settings {
info!("Updating index settings");
index
.set_settings(&settings)
.await
.inspect_err(|e| {
error!("Error setting index settings: {e:?}")
})?
.wait_for_completion(
client,
None,
Some(search_operation_timeout()),
)
.await
.inspect_err(|e| {
error!(
"Error setting index settings while waiting: {e:?}"
)
})?;
}
info!("Done performing index settings set.");
Ok(index)
@@ -291,7 +313,11 @@ async fn create_or_update_index(
// Only create index and set settings if the index doesn't already exist
let task = client.create_index(name, Some("version_id")).await?;
let task = task
.wait_for_completion(client, None, Some(TIMEOUT))
.wait_for_completion(
client,
None,
Some(search_operation_timeout()),
)
.await
.inspect_err(|e| {
error!("Error creating index while waiting: {e:?}")
@@ -306,15 +332,25 @@ async fn create_or_update_index(
settings = settings.with_ranking_rules(custom_rules);
}
index
.set_settings(&settings)
.await
.inspect_err(|e| error!("Error setting index settings: {e:?}"))?
.wait_for_completion(client, None, Some(TIMEOUT))
.await
.inspect_err(|e| {
error!("Error setting index settings while waiting: {e:?}")
})?;
if update_settings {
index
.set_settings(&settings)
.await
.inspect_err(|e| {
error!("Error setting index settings: {e:?}")
})?
.wait_for_completion(
client,
None,
Some(search_operation_timeout()),
)
.await
.inspect_err(|e| {
error!(
"Error setting index settings while waiting: {e:?}"
)
})?;
}
Ok(index)
}
@@ -436,10 +472,10 @@ async fn update_and_add_to_index(
// // Allow a long timeout for adding new attributes- it only needs to happen the once
// filterable_task
// .wait_for_completion(client, None, Some(TIMEOUT * 100))
// .wait_for_completion(client, None, Some(search_operation_timeout() * 100))
// .await?;
// displayable_task
// .wait_for_completion(client, None, Some(TIMEOUT * 100))
// .wait_for_completion(client, None, Some(search_operation_timeout() * 100))
// .await?;
// }
+1 -1
View File
@@ -283,7 +283,6 @@ pub fn get_sort_index(
pub async fn search_for_project(
info: &SearchRequest,
config: &SearchConfig,
client: &MeilisearchReadClient,
) -> Result<SearchResults, SearchError> {
let offset: usize = info.offset.as_deref().unwrap_or("0").parse()?;
let index = info.index.as_deref().unwrap_or("relevance");
@@ -295,6 +294,7 @@ pub async fn search_for_project(
.min(100);
let sort = get_sort_index(config, index)?;
let client = config.make_loadbalanced_read_client()?;
let meilisearch_index = client.get_index(sort.0).await?;
let mut filter_string = String::new();
+2 -2
View File
@@ -89,7 +89,7 @@ impl TemporaryDatabase {
println!("Migrations complete");
// Gets new Redis pool
let redis_pool = RedisPool::new(Some(temp_database_name.clone()));
let redis_pool = RedisPool::new(temp_database_name.clone());
// Create new meilisearch config
let search_config =
@@ -194,7 +194,7 @@ impl TemporaryDatabase {
pool: pool.clone(),
ro_pool: ReadOnlyPgPool::from(pool.clone()),
database_name: TEMPLATE_DATABASE_NAME.to_string(),
redis_pool: RedisPool::new(Some(name.clone())),
redis_pool: RedisPool::new(name.clone()),
search_config: search::SearchConfig::new(Some(name)),
};
let setup_api =
+4 -2
View File
@@ -23,10 +23,12 @@ pub async fn set(settings: Settings) -> crate::Result<()> {
}
#[tracing::instrument]
pub async fn cancel_directory_change() -> crate::Result<()> {
pub async fn cancel_directory_change(
app_identifier: &str,
) -> crate::Result<()> {
// This is called to handle state initialization errors due to folder migrations
// failing, so fetching a DB connection pool from `State::get` is not reliable here
let pool = crate::state::db::connect().await?;
let pool = crate::state::db::connect(app_identifier).await?;
let mut settings = Settings::get(&pool).await?;
if let Some(prev_custom_dir) = settings.prev_custom_dir {
+5 -3
View File
@@ -18,7 +18,7 @@
// Handling for the live development logging
// This will log to the console, and will not log to a file
#[cfg(debug_assertions)]
pub fn start_logger() -> Option<()> {
pub fn start_logger(_app_identifier: &str) -> Option<()> {
use tracing_subscriber::prelude::*;
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
@@ -36,7 +36,7 @@ pub fn start_logger() -> Option<()> {
// Handling for the live production logging
// This will log to a file in the logs directory, and will not show any logs in the console
#[cfg(not(debug_assertions))]
pub fn start_logger() -> Option<()> {
pub fn start_logger(app_identifier: &str) -> Option<()> {
use crate::prelude::DirectoryInfo;
use chrono::Local;
use std::fs::OpenOptions;
@@ -44,7 +44,9 @@ pub fn start_logger() -> Option<()> {
use tracing_subscriber::prelude::*;
// Initialize and get logs directory path
let logs_dir = if let Some(d) = DirectoryInfo::launcher_logs_dir() {
let logs_dir = if let Some(d) =
DirectoryInfo::launcher_logs_dir_path(app_identifier)
{
d
} else {
eprintln!("Could not start logger");
+6 -5
View File
@@ -6,12 +6,13 @@ use sqlx::{Pool, Sqlite};
use std::str::FromStr;
use std::time::Duration;
pub(crate) async fn connect() -> crate::Result<Pool<Sqlite>> {
let settings_dir = DirectoryInfo::get_initial_settings_dir().ok_or(
crate::ErrorKind::FSError(
pub(crate) async fn connect(
app_identifier: &str,
) -> crate::Result<Pool<Sqlite>> {
let settings_dir = DirectoryInfo::initial_settings_dir_path(app_identifier)
.ok_or(crate::ErrorKind::FSError(
"Could not find valid config dir".to_string(),
),
)?;
))?;
if !settings_dir.exists() {
crate::util::io::create_dir_all(&settings_dir).await?;
+32 -13
View File
@@ -1,6 +1,7 @@
//! Theseus directory information
use crate::LoadingBarType;
use crate::event::emit::{emit_loading, init_loading};
use crate::state::LAUNCHER_STATE;
use crate::state::{JavaVersion, Profile, Settings};
use crate::util::fetch::IoSemaphore;
use dashmap::DashSet;
@@ -17,24 +18,35 @@ pub const METADATA_FOLDER_NAME: &str = "meta";
pub struct DirectoryInfo {
pub settings_dir: PathBuf, // Base settings directory- app database
pub config_dir: PathBuf, // Base config directory- instances, minecraft downloads, etc. Changeable as a setting.
pub app_identifier: String,
}
impl DirectoryInfo {
pub fn global_handle_if_ready() -> Option<&'static Self> {
LAUNCHER_STATE.get().map(|x| &x.directories)
}
pub fn get_initial_settings_dir(&self) -> Option<PathBuf> {
Self::initial_settings_dir_path(&self.app_identifier)
}
// Get the settings directory
// init() is not needed for this function
pub fn get_initial_settings_dir() -> Option<PathBuf> {
pub fn initial_settings_dir_path(app_identifier: &str) -> Option<PathBuf> {
Self::env_path("THESEUS_CONFIG_DIR")
.or_else(|| Some(dirs::data_dir()?.join("ModrinthApp")))
.or_else(|| Some(dirs::data_dir()?.join(app_identifier)))
}
/// Get all paths needed for Theseus to operate properly
#[tracing::instrument]
pub async fn init(config_dir: Option<String>) -> crate::Result<Self> {
let settings_dir = Self::get_initial_settings_dir().ok_or(
crate::ErrorKind::FSError(
pub async fn init(
config_dir: Option<String>,
app_identifier: &str,
) -> crate::Result<Self> {
let settings_dir = Self::initial_settings_dir_path(app_identifier)
.ok_or(crate::ErrorKind::FSError(
"Could not find valid settings dir".to_string(),
),
)?;
))?;
fs::create_dir_all(&settings_dir).await.map_err(|err| {
crate::ErrorKind::FSError(format!(
@@ -48,6 +60,7 @@ impl DirectoryInfo {
Ok(Self {
settings_dir,
config_dir,
app_identifier: app_identifier.to_owned(),
})
}
@@ -154,8 +167,14 @@ impl DirectoryInfo {
}
#[inline]
pub fn launcher_logs_dir() -> Option<PathBuf> {
Self::get_initial_settings_dir()
pub fn launcher_logs_dir(&self) -> Option<PathBuf> {
self.get_initial_settings_dir()
.map(|d| d.join(LAUNCHER_LOGS_FOLDER_NAME))
}
#[inline]
pub fn launcher_logs_dir_path(app_identifier: &str) -> Option<PathBuf> {
Self::initial_settings_dir_path(app_identifier)
.map(|d| d.join(LAUNCHER_LOGS_FOLDER_NAME))
}
@@ -176,15 +195,15 @@ impl DirectoryInfo {
settings: &mut Settings,
exec: E,
io_semaphore: &IoSemaphore,
app_identifier: &str,
) -> crate::Result<()>
where
E: sqlx::Executor<'a, Database = sqlx::Sqlite> + Copy,
{
let app_dir = DirectoryInfo::get_initial_settings_dir().ok_or(
crate::ErrorKind::FSError(
let app_dir = DirectoryInfo::initial_settings_dir_path(app_identifier)
.ok_or(crate::ErrorKind::FSError(
"Could not find valid config dir".to_string(),
),
)?;
))?;
if let Some(ref prev_custom_dir) = settings.prev_custom_dir {
let prev_dir = PathBuf::from(prev_custom_dir);
+14 -5
View File
@@ -71,6 +71,9 @@ pub struct State {
/// Process manager
pub process_manager: ProcessManager,
/// App identifier string (like com.modrinth.ModrinthApp)
pub app_identifier: String,
/// Friends socket
pub friends_socket: FriendsSocket,
@@ -80,9 +83,9 @@ pub struct State {
}
impl State {
pub async fn init() -> crate::Result<()> {
pub async fn init(app_identifier: String) -> crate::Result<()> {
let state = LAUNCHER_STATE
.get_or_try_init(Self::initialize_state)
.get_or_try_init(move || Self::initialize_state(app_identifier))
.await?;
tokio::task::spawn(async move {
@@ -132,9 +135,11 @@ impl State {
}
#[tracing::instrument]
async fn initialize_state() -> crate::Result<Arc<Self>> {
async fn initialize_state(
app_identifier: String,
) -> crate::Result<Arc<Self>> {
tracing::info!("Connecting to app database");
let pool = db::connect().await?;
let pool = db::connect(&app_identifier).await?;
legacy_converter::migrate_legacy_data(&pool).await?;
@@ -153,9 +158,12 @@ impl State {
&mut settings,
&pool,
&io_semaphore,
&app_identifier,
)
.await?;
let directories = DirectoryInfo::init(settings.custom_dir).await?;
let directories =
DirectoryInfo::init(settings.custom_dir, &app_identifier).await?;
let discord_rpc = DiscordGuard::init()?;
@@ -177,6 +185,7 @@ impl State {
friends_socket,
pool,
file_watcher,
app_identifier,
}))
}
}
@@ -28,11 +28,13 @@
'--_width': width,
}"
>
<div class="modal-body flex flex-col bg-bg-raised rounded-2xl">
<div
class="modal-body flex flex-col bg-bg-raised rounded-2xl border border-solid border-surface-5"
>
<div
v-if="!hideHeader"
data-tauri-drag-region
class="grid grid-cols-[auto_min-content] items-center gap-4 p-6 border-solid border-0 border-b-[1px] border-divider max-w-full"
class="grid grid-cols-[auto_min-content] items-center gap-4 p-6 border-solid border-0 border-b-[1px] border-surface-5 max-w-full"
>
<div class="flex text-wrap break-words items-center gap-3 min-w-0">
<slot name="title">
@@ -43,7 +43,7 @@
<XCircleIcon v-else-if="item.type === 'error'" class="h-6 w-6" />
<InfoIcon v-else class="h-6 w-6" />
</div>
<div class="m-0 text-wrap font-bold text-contrast" v-html="item.title"></div>
<div class="m-0 text-wrap font-bold text-contrast">{{ item.title }}</div>
<div class="flex items-center gap-1">
<div v-if="item.count && item.count > 1" class="text-xs font-bold text-contrast">
x{{ item.count }}
@@ -66,13 +66,12 @@
</ButtonStyled>
</div>
<div></div>
<div class="col-span-2 text-sm text-primary" v-html="item.text"></div>
<div class="col-span-2 text-sm text-primary">{{ item.text }}</div>
<template v-if="item.errorCode">
<div></div>
<div
class="m-0 text-wrap text-xs font-medium text-secondary"
v-html="item.errorCode"
></div>
<div class="m-0 text-wrap text-xs font-medium text-secondary">
{{ item.errorCode }}
</div>
</template>
</div>
</div>
@@ -25,7 +25,7 @@
v-if="isOpen"
ref="menuRef"
data-pyro-telepopover-root
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-divider bg-bg-raised p-2 shadow-lg"
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
:style="menuStyle"
role="menu"
tabindex="-1"
+8 -7
View File
@@ -1,6 +1,7 @@
import { computed, type ComputedRef } from 'vue'
import { injectI18n } from '../providers/i18n'
import { LOCALES } from './i18n.ts'
export type Formatter = (value: Date | number | null | undefined, options?: FormatOptions) => string
@@ -13,13 +14,13 @@ const formatters = new Map<string, ComputedRef<Intl.RelativeTimeFormat>>()
export function useRelativeTime(): Formatter {
const { locale } = injectI18n()
const formatterRef = computed(
() =>
new Intl.RelativeTimeFormat(locale.value, {
numeric: 'auto',
style: 'long',
}),
)
const formatterRef = computed(() => {
const localeDefinition = LOCALES.find((loc) => loc.code === locale.value)
return new Intl.RelativeTimeFormat(locale.value, {
numeric: localeDefinition?.numeric || 'auto',
style: 'long',
})
})
if (!formatters.has(locale.value)) {
formatters.set(locale.value, formatterRef)
+8 -7
View File
@@ -27,6 +27,7 @@ export function defineMessages<K extends string, T extends MessageDescriptorMap<
export interface LocaleDefinition {
code: string
name: string
numeric?: Intl.RelativeTimeFormatNumeric
dir?: 'ltr' | 'rtl'
iso?: string
file?: string
@@ -56,31 +57,31 @@ export const LOCALES: LocaleDefinition[] = [
// { code: 'et-EE', name: 'Eesti' },
// { code: 'fa-IR', name: 'فارسی', dir: 'rtl' },
// { code: 'fi-FI', name: 'Suomi' },
// { code: 'fil-PH', name: 'Filipino' },
{ code: 'fil-PH', name: 'Filipino' },
{ code: 'fr-FR', name: 'Français' },
// { code: 'he-IL', name: 'עברית', dir: 'rtl' },
// { code: 'hi-IN', name: 'हिन्दी' },
// { code: 'hr-HR', name: 'Hrvatski' },
// { code: 'hu-HU', name: 'Magyar' },
// { code: 'id-ID', name: 'Bahasa Indonesia' },
{ code: 'id-ID', name: 'Bahasa Indonesia' },
// { code: 'is-IS', name: 'Íslenska' },
{ code: 'it-IT', name: 'Italiano' },
{ code: 'it-IT', name: 'Italiano', numeric: 'always' },
// { code: 'ja-JP', name: '日本語' },
// { code: 'kk-KZ', name: 'Қазақша' },
// { code: 'ko-KR', name: '한국어' },
{ code: 'ko-KR', name: '한국어' },
// { code: 'ky-KG', name: 'Кыргызча' },
// { code: 'lol-US', name: 'LOLCAT' },
// { code: 'lt-LT', name: 'Lietuvių' },
// { code: 'lv-LV', name: 'Latviešu' },
// { code: 'ms-Arab', name: 'بهاس ملايو (جاوي)', dir: 'rtl' },
{ code: 'ms-MY', name: 'Bahasa Melayu' },
// { code: 'nl-NL', name: 'Nederlands' },
{ code: 'nl-NL', name: 'Nederlands' },
// { code: 'no-NO', name: 'Norsk' },
{ code: 'pl-PL', name: 'Polski' },
{ code: 'pt-BR', name: 'Português (Brasil)' },
{ code: 'pt-PT', name: 'Português (Portugal)' },
// { code: 'ro-RO', name: 'Română' },
{ code: 'ru-RU', name: 'Русский' },
{ code: 'ru-RU', name: 'Русский', numeric: 'always' },
// { code: 'sk-SK', name: 'Slovenčina' },
// { code: 'sl-SI', name: 'Slovenščina' },
// { code: 'sr-CS', name: 'Српски (ћирилица)' },
@@ -91,7 +92,7 @@ export const LOCALES: LocaleDefinition[] = [
{ code: 'tr-TR', name: 'Türkçe' },
// { code: 'tt-RU', name: 'Татарча' },
{ code: 'uk-UA', name: 'Українська' },
// { code: 'vi-VN', name: 'Tiếng Việt' },
{ code: 'vi-VN', name: 'Tiếng Việt' },
{ code: 'zh-CN', name: '简体中文' },
{ code: 'zh-TW', name: '繁體中文' },
]
+15
View File
@@ -248,6 +248,18 @@
"form.placeholder.state": {
"defaultMessage": "Enter state/province"
},
"header.category.category": {
"defaultMessage": "Category"
},
"header.category.feature": {
"defaultMessage": "Feature"
},
"header.category.performance-impact": {
"defaultMessage": "Performance impact"
},
"header.category.resolutions": {
"defaultMessage": "Resolutions"
},
"hosting.specs.burst": {
"defaultMessage": "Bursts up to {cpus} CPUs"
},
@@ -677,6 +689,9 @@
"project-type.plugin.lowercase": {
"defaultMessage": "{count, plural, one {plugin} other {plugins}}"
},
"project-type.project.capital": {
"defaultMessage": "{count, plural, one {Project} other {Projects}}"
},
"project-type.project.category": {
"defaultMessage": "Projects"
},
+1 -1
View File
@@ -557,7 +557,7 @@ export const commonProjectTypeTitleMessages = defineMessages({
defaultMessage: '{count, plural, one {Server} other {Servers}}',
},
project: {
id: 'project-type.project.lowercase',
id: 'project-type.project.capital',
defaultMessage: '{count, plural, one {Project} other {Projects}}',
},
})
+3 -3
View File
@@ -1,11 +1,11 @@
import type { Labrinth } from '@modrinth/api-client'
import { ClientIcon, getCategoryIcon, getLoaderIcon, ServerIcon } from '@modrinth/assets'
import { formatCategoryHeader, sortByNameOrNumber } from '@modrinth/utils'
import { sortByNameOrNumber } from '@modrinth/utils'
import { type Component, computed, readonly, type Ref, ref } from 'vue'
import { type LocationQueryRaw, type LocationQueryValue, useRoute } from 'vue-router'
import { defineMessage, useVIntl } from '../composables/i18n'
import { formatCategory, formatLoader } from './tag-messages.ts'
import { formatCategory, formatCategoryHeader, formatLoader } from './tag-messages.ts'
type BaseOption = {
id: string
@@ -132,7 +132,7 @@ export function useSearch(
if (!categoryFilters[filterTypeId]) {
categoryFilters[filterTypeId] = {
id: filterTypeId,
formatted_name: formatCategoryHeader(category.header),
formatted_name: formatCategoryHeader(formatMessage, category.header),
supported_project_types:
category.project_type === 'mod'
? ['mod', 'plugin', 'datapack']
+29 -1
View File
@@ -388,6 +388,25 @@ export const categoryMessages = defineMessages({
},
})
export const categoryHeaderMessages = defineMessages({
resolutions: {
id: 'header.category.resolutions',
defaultMessage: 'Resolutions',
},
categories: {
id: 'header.category.category',
defaultMessage: 'Category',
},
features: {
id: 'header.category.feature',
defaultMessage: 'Feature',
},
'performance impact': {
id: 'header.category.performance-impact',
defaultMessage: 'Performance impact',
},
})
export function getTagMessage(
tag: string,
enforceType?: 'loader' | 'category',
@@ -409,6 +428,10 @@ export function getCategoryMessage(category: string) {
return getTagMessage(category, 'category')
}
export function getCategoryHeaderMessage(header: string): MessageDescriptor | undefined {
return categoryHeaderMessages[header]
}
export function formatTag(
formatter: VIntlFormatters['formatMessage'],
tag: string,
@@ -423,5 +446,10 @@ export function formatCategory(formatter: VIntlFormatters['formatMessage'], cate
}
export function formatLoader(formatter: VIntlFormatters['formatMessage'], category: string) {
return formatTag(formatter, category, 'category')
return formatTag(formatter, category, 'loader')
}
export function formatCategoryHeader(formatter: VIntlFormatters['formatMessage'], header: string) {
const message = getCategoryHeaderMessage(header)
return message ? formatter(message) : capitalizeString(header)
}
+32
View File
@@ -10,6 +10,38 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-02-04T16:00:00-08:00`,
product: 'app',
version: '0.10.28',
body: `## Improvements
- Added app update notification for Linux users.
- Fixed security policy issue updating capes.
- Adjusted pop-up design to include a border.
- Updated translations.`,
},
{
date: `2026-02-04T15:00:00-08:00`,
product: 'web',
body: `## Improvements
- Adjusted pop-up design to include a border.
- Changed Russian and Italian to always use numeric relative dates.
- Made category headers translatable.
- Fixed issue withdrawing gift cards in other currencies.`,
},
{
date: `2026-02-03T09:15:00-08:00`,
product: 'web',
body: `## Improvements
- Fixed some .jar files being detected as Resource Packs when uploading new versions.`,
},
{
date: `2026-02-02T13:20:00-08:00`,
product: 'web',
body: `## Improvements
- Made some clarity improvements to the Personal Access Token screen.
- Updated translations and made mod loader in a number of places translatable.`,
},
{
date: `2026-01-30T13:10:00-08:00`,
product: 'web',
-60
View File
@@ -162,66 +162,6 @@ export const formatProjectType = (name, short = false) => {
return capitalizeString(name)
}
/**
* @deprecated Use tag-messages.ts' `formatTag(tag)` instead.
*/
export const formatCategory = (name) => {
if (name === 'modloader') {
return "Risugami's ModLoader"
} else if (name === 'bungeecord') {
return 'BungeeCord'
} else if (name === 'liteloader') {
return 'LiteLoader'
} else if (name === 'neoforge') {
return 'NeoForge'
} else if (name === 'game-mechanics') {
return 'Game Mechanics'
} else if (name === 'worldgen') {
return 'World Generation'
} else if (name === 'core-shaders') {
return 'Core Shaders'
} else if (name === 'gui') {
return 'GUI'
} else if (name === '8x-') {
return '8x or lower'
} else if (name === '512x+') {
return '512x or higher'
} else if (name === 'kitchen-sink') {
return 'Kitchen Sink'
} else if (name === 'path-tracing') {
return 'Path Tracing'
} else if (name === 'pbr') {
return 'PBR'
} else if (name === 'datapack') {
return 'Data Pack'
} else if (name === 'colored-lighting') {
return 'Colored Lighting'
} else if (name === 'optifine') {
return 'OptiFine'
} else if (name === 'bta-babric') {
return 'BTA (Babric)'
} else if (name === 'legacy-fabric') {
return 'Legacy Fabric'
} else if (name === 'java-agent') {
return 'Java Agent'
} else if (name === 'nilloader') {
return 'NilLoader'
} else if (name === 'mrpack') {
return 'Modpack'
} else if (name === 'minecraft') {
return 'Resource Pack'
} else if (name === 'vanilla') {
return 'Vanilla Shader'
} else if (name === 'geyser') {
return 'Geyser Extension'
}
return capitalizeString(name)
}
export const formatCategoryHeader = (name) => {
return capitalizeString(name)
}
export const formatProjectStatus = (name) => {
if (name === 'approved') {
return 'Public'