mirror of
https://github.com/modrinth/code.git
synced 2026-08-03 06:35:53 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63a3d12f79 | ||
|
|
0438804abb | ||
|
|
01e11b836e | ||
|
|
5501a0c6df | ||
|
|
695233e736 | ||
|
|
8789d7b057 | ||
|
|
510ea6cde4 | ||
|
|
b1954be2c7 | ||
|
|
9105a68923 | ||
|
|
06e2f59a94 | ||
|
|
ddb013e024 | ||
|
|
3f5e3b1d8b | ||
|
|
323090966b | ||
|
|
16204d30f8 | ||
|
|
34cbc7e0c1 | ||
|
|
5d6593a9da | ||
|
|
ab753a82bc |
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
+174
-53
@@ -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"
|
||||
|
||||
@@ -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('')
|
||||
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Generated
-22
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '繁體中文' },
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user