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
50 changed files with 1299 additions and 268 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
+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 = { version = "0.22.1", git = "https://github.com/modrinth/deadpool", rev = "db5fb00b036ecc8fe5f18853c559b745ffe47bde" }
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"
+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,
}"
@@ -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
@@ -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">
@@ -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"
+6 -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"
@@ -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 {
@@ -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',
@@ -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: {
@@ -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"
}
+7 -6
View File
@@ -14,6 +14,7 @@ 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, info_span};
use util::{cmd, redis_pipe};
@@ -27,20 +28,20 @@ const ACTUAL_EXPIRY: i64 = 60 * 30; // 30 minutes
pub struct RedisPool {
pub url: String,
pub pool: deadpool_redis::Pool,
cache_list: DashMap<String, util::CacheSubscriber>,
meta_namespace: String,
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),
@@ -71,8 +72,8 @@ impl RedisPool {
let pool = RedisPool {
url,
pool,
cache_list: DashMap::with_capacity(2048),
meta_namespace: meta_namespace.unwrap_or("".to_string()),
cache_list: Arc::new(DashMap::with_capacity(2048)),
meta_namespace: meta_namespace.into(),
};
let redis_min_connections = dotenvy::var("REDIS_MIN_CONNECTIONS")
+12 -12
View File
@@ -325,16 +325,6 @@ pub fn app_config(
.app_data(web::Data::new(labrinth_config.stripe_client.clone()))
.app_data(web::Data::new(labrinth_config.anrok_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)
@@ -346,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;
+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
)
+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?;
+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 =
@@ -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: '繁體中文' },
]
+12
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"
},
+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']
+28
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,
@@ -425,3 +448,8 @@ export function formatCategory(formatter: VIntlFormatters['formatMessage'], cate
export function formatLoader(formatter: VIntlFormatters['formatMessage'], category: string) {
return formatTag(formatter, category, 'loader')
}
export function formatCategoryHeader(formatter: VIntlFormatters['formatMessage'], header: string) {
const message = getCategoryHeaderMessage(header)
return message ? formatter(message) : capitalizeString(header)
}
+26 -1
View File
@@ -11,7 +11,32 @@ export type VersionEntry = {
const VERSIONS: VersionEntry[] = [
{
date: `2026-02-01T13:20:00-08:00`,
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.
-4
View File
@@ -162,10 +162,6 @@ export const formatProjectType = (name, short = false) => {
return capitalizeString(name)
}
export const formatCategoryHeader = (name) => {
return capitalizeString(name)
}
export const formatProjectStatus = (name) => {
if (name === 'approved') {
return 'Public'