mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63a3d12f79 | ||
|
|
0438804abb | ||
|
|
01e11b836e | ||
|
|
5501a0c6df | ||
|
|
695233e736 | ||
|
|
8789d7b057 | ||
|
|
510ea6cde4 | ||
|
|
b1954be2c7 | ||
|
|
9105a68923 | ||
|
|
06e2f59a94 | ||
|
|
ddb013e024 | ||
|
|
3f5e3b1d8b | ||
|
|
323090966b | ||
|
|
16204d30f8 | ||
|
|
34cbc7e0c1 | ||
|
|
5d6593a9da | ||
|
|
ab753a82bc | ||
|
|
880e759336 | ||
|
|
84b386141c | ||
|
|
e9a4cc60ca | ||
|
|
d5869c514e | ||
|
|
de0a03b2e9 | ||
|
|
3f6c79b00d | ||
|
|
56c8bb1950 | ||
|
|
f81f951814 | ||
|
|
345ada27c0 | ||
|
|
e3395a7366 |
@@ -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
|
||||
|
||||
@@ -19,7 +19,7 @@ on:
|
||||
sign-windows-binaries:
|
||||
description: Sign Windows binaries
|
||||
type: boolean
|
||||
default: true
|
||||
default: false
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
@@ -40,33 +40,48 @@ jobs:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: 📥 Check out code
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🧰 Setup Rust toolchain
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
rustflags: ''
|
||||
target: ${{ startsWith(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: 🧰 Install pnpm
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: 🧰 Setup Node.js
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
|
||||
- name: 🧰 Install Linux build dependencies
|
||||
- name: Generate tauri-dev.conf.json
|
||||
shell: bash
|
||||
run: |
|
||||
GIT_HASH=$(git rev-parse --short HEAD)
|
||||
cat > apps/app/tauri-dev.conf.json <<EOF
|
||||
{
|
||||
"productName": "Modrinth App (dev-${GIT_HASH})",
|
||||
"mainBinaryName": "Modrinth App (dev-${GIT_HASH})",
|
||||
"identifier": "ModrinthApp-dev-${GIT_HASH}",
|
||||
"bundle": {
|
||||
"fileAssociations": []
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
|
||||
- name: 🧰 Setup Dasel
|
||||
- name: Setup Dasel
|
||||
uses: jaxxstorm/action-install-gh-release@v2.1.0
|
||||
with:
|
||||
repo: TomWright/dasel
|
||||
@@ -75,7 +90,7 @@ jobs:
|
||||
rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
|
||||
chmod: 0755
|
||||
|
||||
- name: ⚙️ Set application version and environment
|
||||
- name: Set application version and environment
|
||||
shell: bash
|
||||
run: |
|
||||
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
|
||||
@@ -86,13 +101,13 @@ jobs:
|
||||
|
||||
cp packages/app-lib/.env.prod packages/app-lib/.env
|
||||
|
||||
- name: 💨 Setup Turbo cache
|
||||
- name: Setup Turbo cache
|
||||
uses: rharkor/caching-for-turbo@v1.8
|
||||
|
||||
- name: 🧰 Install dependencies
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: ✍️ Set up Windows code signing
|
||||
- name: Set up Windows code signing
|
||||
if: startsWith(matrix.platform, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -102,8 +117,8 @@ jobs:
|
||||
dasel delete -f apps/app/tauri-release.conf.json 'bundle.windows.signCommand'
|
||||
fi
|
||||
|
||||
- name: 🔨 Build macOS app
|
||||
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
|
||||
- name: Build macOS app
|
||||
run: ${{ github.ref == 'refs/heads/main' && 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-dev.conf.json' }}
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
env:
|
||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -116,19 +131,19 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: 🔨 Build Linux app
|
||||
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
|
||||
- name: Build Linux app
|
||||
run: ${{ github.ref == 'refs/heads/main' && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json' }}
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: 🔨 Build Windows app
|
||||
- name: Build Windows app
|
||||
run: |
|
||||
[System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream
|
||||
$env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD"
|
||||
$env:JAVA_HOME = "$env:JAVA_HOME_17_X64"
|
||||
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater'
|
||||
${{ github.ref == 'refs/heads/main' && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles "nsis,updater"' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json --verbose --bundles "nsis,updater"' }}
|
||||
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
|
||||
if: startsWith(matrix.platform, 'windows')
|
||||
env:
|
||||
@@ -138,7 +153,7 @@ jobs:
|
||||
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64 }}
|
||||
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: 📤 Upload app bundles
|
||||
- name: Upload app bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: App bundle (${{ matrix.artifact-target-name }})
|
||||
@@ -146,7 +161,13 @@ jobs:
|
||||
target/release/bundle/appimage/Modrinth App_*.AppImage*
|
||||
target/release/bundle/deb/Modrinth App_*.deb*
|
||||
target/release/bundle/rpm/Modrinth App-*.rpm*
|
||||
target/release/bundle/appimage/Modrinth App (dev-*)_*.AppImage*
|
||||
target/release/bundle/deb/Modrinth App (dev-*)_*.deb*
|
||||
target/release/bundle/rpm/Modrinth App (dev-*)-*.rpm*
|
||||
target/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz*
|
||||
target/universal-apple-darwin/release/bundle/macos/Modrinth App (dev-*)*.app.tar.gz
|
||||
target/universal-apple-darwin/release/bundle/dmg/Modrinth App (dev-*)*.dmg
|
||||
target/universal-apple-darwin/release/bundle/dmg/Modrinth App_*.dmg*
|
||||
target/release/bundle/nsis/Modrinth App_*-setup.exe*
|
||||
target/release/bundle/nsis/Modrinth App (dev-*)_*-setup.exe*
|
||||
target/release/bundle/nsis/Modrinth App_*-setup.nsis.zip*
|
||||
|
||||
Generated
+7
-9
@@ -2247,30 +2247,28 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
[[package]]
|
||||
name = "deadpool"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
|
||||
source = "git+https://github.com/modrinth/deadpool?rev=db5fb00b036ecc8fe5f18853c559b745ffe47bde#db5fb00b036ecc8fe5f18853c559b745ffe47bde"
|
||||
dependencies = [
|
||||
"deadpool-runtime",
|
||||
"lazy_static",
|
||||
"num_cpus",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool-redis"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0965b977f1244bc3783bb27cd79cfcff335a8341da18f79232d00504b18eb1a"
|
||||
version = "0.22.1"
|
||||
source = "git+https://github.com/modrinth/deadpool?rev=db5fb00b036ecc8fe5f18853c559b745ffe47bde#db5fb00b036ecc8fe5f18853c559b745ffe47bde"
|
||||
dependencies = [
|
||||
"deadpool",
|
||||
"redis",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool-runtime"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||
version = "0.1.5"
|
||||
source = "git+https://github.com/modrinth/deadpool?rev=db5fb00b036ecc8fe5f18853c559b745ffe47bde#db5fb00b036ecc8fe5f18853c559b745ffe47bde"
|
||||
dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ const_format = "0.2.34"
|
||||
daedalus = { path = "packages/daedalus" }
|
||||
dashmap = "6.1.0"
|
||||
data-url = "0.3.2"
|
||||
deadpool-redis = "0.22.0"
|
||||
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "db5fb00b036ecc8fe5f18853c559b745ffe47bde", version = "0.22.1" }
|
||||
derive_more = "2.0.1"
|
||||
directories = "6.0.0"
|
||||
dirs = "6.0.0"
|
||||
|
||||
@@ -17,3 +17,7 @@ extend-exclude = [
|
||||
tou = "tou"
|
||||
# Google Ad Manager
|
||||
gam = "gam"
|
||||
# short for "constants"
|
||||
consts = "consts"
|
||||
# short for "Copy"
|
||||
Cpy = "Cpy"
|
||||
|
||||
@@ -1002,7 +1002,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<transition name="popup-survey">
|
||||
<div
|
||||
v-if="availableSurvey"
|
||||
class="w-[400px] z-20 fixed -bottom-12 pb-16 right-[--right-bar-width] mr-4 rounded-t-2xl card-shadow bg-bg-raised border-divider border-[1px] border-solid border-b-0 p-4"
|
||||
class="w-[400px] z-20 fixed -bottom-12 pb-16 right-[--right-bar-width] mr-4 rounded-t-2xl card-shadow bg-bg-raised border-surface-5 border-[1px] border-solid border-b-0 p-4"
|
||||
>
|
||||
<h2 class="text-lg font-extrabold mt-0 mb-2">Hey there Modrinth user!</h2>
|
||||
<p class="m-0 leading-tight">
|
||||
|
||||
@@ -10,8 +10,13 @@ import {
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button, DropdownSelect, injectNotificationManager } from '@modrinth/ui'
|
||||
import { formatCategoryHeader } from '@modrinth/utils'
|
||||
import {
|
||||
Button,
|
||||
DropdownSelect,
|
||||
formatLoader,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref } from 'vue'
|
||||
@@ -23,6 +28,8 @@ import { duplicate, remove } from '@/helpers/profile.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps({
|
||||
instances: {
|
||||
type: Array,
|
||||
@@ -175,7 +182,7 @@ const filteredResults = computed(() => {
|
||||
|
||||
if (group === 'Loader') {
|
||||
instances.forEach((instance) => {
|
||||
const loader = formatCategoryHeader(instance.loader)
|
||||
const loader = formatLoader(formatMessage, instance.loader)
|
||||
if (!instanceMap.has(loader)) {
|
||||
instanceMap.set(loader, [])
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ const messages = defineMessages({
|
||||
<template>
|
||||
<div
|
||||
v-if="availableUpdate && !dismissed"
|
||||
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-divider border-solid border-[2px]"
|
||||
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-surface-5 border-solid border-[2px]"
|
||||
>
|
||||
<div class="flex min-w-[25rem] gap-4">
|
||||
<h2 class="whitespace-nowrap text-base text-contrast font-semibold m-0 grow">
|
||||
|
||||
@@ -68,7 +68,7 @@ const messages = defineMessages({
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-divider border-solid border-[2px]"
|
||||
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-surface-5 border-solid border-[2px]"
|
||||
:class="{
|
||||
'download-complete': progress === 1,
|
||||
}"
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
:custom-label="
|
||||
(version) =>
|
||||
`${version?.name} (${version?.loaders
|
||||
.map((name) => formatCategory(name))
|
||||
.map((name) => formatLoader(formatMessage, name))
|
||||
.join(', ')} - ${version?.game_versions.join(', ')})`
|
||||
"
|
||||
:max-height="150"
|
||||
@@ -36,7 +36,9 @@
|
||||
<span v-else>
|
||||
<span>
|
||||
{{ selectedVersion?.name }} ({{
|
||||
selectedVersion?.loaders.map((name) => formatCategory(name)).join(', ')
|
||||
selectedVersion?.loaders
|
||||
.map((name) => formatLoader(formatMessage, name))
|
||||
.join(', ')
|
||||
}}
|
||||
- {{ selectedVersion?.game_versions.join(', ') }})
|
||||
</span>
|
||||
@@ -57,8 +59,7 @@
|
||||
|
||||
<script setup>
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import { Button, injectNotificationManager } from '@modrinth/ui'
|
||||
import { formatCategory } from '@modrinth/utils'
|
||||
import { Button, formatLoader, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
import Multiselect from 'vue-multiselect'
|
||||
|
||||
@@ -67,6 +68,7 @@ import { trackEvent } from '@/helpers/analytics'
|
||||
import { add_project_from_version as installMod } from '@/helpers/profile'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const instance = ref(null)
|
||||
const project = ref(null)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
|
||||
<div class="flex flex-col gap-6 max-w-[500px]">
|
||||
<Admonition type="info" :header="formatMessage(messages.sharedServerInstance)">
|
||||
{{ formatMessage(messages.serverRequiresMods) }}
|
||||
</Admonition>
|
||||
|
||||
<div v-if="sharedBy?.name" class="flex items-center gap-2 text-sm text-secondary">
|
||||
<Avatar
|
||||
v-if="sharedBy?.icon_url"
|
||||
:src="sharedBy.icon_url"
|
||||
:alt="sharedBy.name"
|
||||
size="24px"
|
||||
/>
|
||||
<span>
|
||||
<IntlFormatted :message-id="messages.sharedByToday">
|
||||
<template #~name>
|
||||
<span class="font-semibold text-contrast">{{ sharedBy.name }}</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.sharedInstance) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-3 rounded-xl bg-surface-4 p-3">
|
||||
<Avatar :src="project.icon_url" :alt="project.title" size="48px" />
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-semibold text-contrast">{{ project.title }}</span>
|
||||
<span class="text-sm text-secondary">
|
||||
{{ loaderDisplay }} {{ project.game_versions?.[0] }}
|
||||
<template v-if="modCount">
|
||||
· {{ formatMessage(messages.modCount, { count: modCount }) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="handleDecline">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleAccept">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.installButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
formatLoader,
|
||||
IntlFormatted,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { get_organization, get_team, get_version } from '@/helpers/cache.js'
|
||||
import { install } from '@/store/install.js'
|
||||
|
||||
const props = defineProps<{
|
||||
project: Labrinth.Projects.v2.Project
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { data: organization } = useQuery({
|
||||
queryKey: computed(() => ['organization', props.project.organization]),
|
||||
queryFn: () => get_organization(props.project.organization!, 'must_revalidate'),
|
||||
enabled: computed(() => !!props.project.organization),
|
||||
})
|
||||
|
||||
const { data: teamMembers } = useQuery({
|
||||
queryKey: computed(() => ['team', props.project.team]),
|
||||
queryFn: () => get_team(props.project.team, 'must_revalidate'),
|
||||
enabled: computed(() => !!props.project.team && !props.project.organization),
|
||||
})
|
||||
|
||||
const sharedBy = computed(() => {
|
||||
if (organization.value) {
|
||||
return {
|
||||
name: organization.value.name,
|
||||
icon_url: organization.value.icon_url,
|
||||
}
|
||||
}
|
||||
if (teamMembers.value) {
|
||||
const owner = teamMembers.value.find((member: { is_owner: boolean }) => member.is_owner)
|
||||
if (owner) {
|
||||
return {
|
||||
name: owner.user.username,
|
||||
icon_url: owner.user.avatar_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const loaderDisplay = computed(() => {
|
||||
const loader = props.project.loaders?.[0]
|
||||
if (!loader) return ''
|
||||
return formatLoader(formatMessage, loader)
|
||||
})
|
||||
|
||||
// Fetch the most recent version to get mod count from dependencies
|
||||
const latestVersionId = computed(() => props.project.versions?.[0] ?? null)
|
||||
const { data: latestVersion } = useQuery({
|
||||
queryKey: computed(() => ['version', latestVersionId.value]),
|
||||
queryFn: () => get_version(latestVersionId.value, 'must_revalidate'),
|
||||
enabled: computed(() => !!latestVersionId.value),
|
||||
})
|
||||
const modCount = computed(() => latestVersion.value?.dependencies?.length)
|
||||
|
||||
async function handleAccept() {
|
||||
hide()
|
||||
try {
|
||||
await install(props.project.id, null, null, 'ProjectPageInstallToPlayModal')
|
||||
} catch (error) {
|
||||
console.error('Failed to install project from InstallToPlayModal:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDecline() {
|
||||
hide()
|
||||
}
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
installToPlay: {
|
||||
id: 'app.modal.install-to-play.header',
|
||||
defaultMessage: 'Install to play',
|
||||
},
|
||||
sharedServerInstance: {
|
||||
id: 'app.modal.install-to-play.shared-server-instance',
|
||||
defaultMessage: 'Shared server instance',
|
||||
},
|
||||
serverRequiresMods: {
|
||||
id: 'app.modal.install-to-play.server-requires-mods',
|
||||
defaultMessage:
|
||||
'This server requires mods to play. Click install to set up the required files from Modrinth.',
|
||||
},
|
||||
sharedByToday: {
|
||||
id: 'app.modal.install-to-play.shared-by-today',
|
||||
defaultMessage: '{name} shared this instance with you today.',
|
||||
},
|
||||
sharedInstance: {
|
||||
id: 'app.modal.install-to-play.shared-instance',
|
||||
defaultMessage: 'Shared instance',
|
||||
},
|
||||
modCount: {
|
||||
id: 'app.modal.install-to-play.mod-count',
|
||||
defaultMessage: '{count, plural, one {# mod} other {# mods}}',
|
||||
},
|
||||
installButton: {
|
||||
id: 'app.modal.install-to-play.install-button',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,382 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.updateToPlay)" :closable="true" no-padding>
|
||||
<div class="max-w-[500px]">
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<Admonition type="warning" :header="formatMessage(messages.updateRequired)">
|
||||
{{ formatMessage(messages.updateRequiredDescription, { name: instance.name }) }}
|
||||
</Admonition>
|
||||
|
||||
<div v-if="diffs.length" class="flex flex-col gap-2">
|
||||
<span v-if="publishedDate" class="text-contrast font-semibold">{{
|
||||
formatMessage(messages.publishedDate, { date: publishedDate })
|
||||
}}</span>
|
||||
<div class="flex gap-2">
|
||||
<div v-if="removedCount" class="flex gap-1 items-center">
|
||||
<MinusIcon />
|
||||
{{ formatMessage(messages.removedCount, { count: removedCount }) }}
|
||||
</div>
|
||||
<div v-if="addedCount" class="flex gap-1 items-center">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.addedCount, { count: addedCount }) }}
|
||||
</div>
|
||||
<div v-if="updatedCount" class="flex gap-1 items-center">
|
||||
<RefreshCwIcon />
|
||||
{{ formatMessage(messages.updatedCount, { count: updatedCount }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="diffs.length" class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto">
|
||||
<div
|
||||
v-for="diff in diffs"
|
||||
:key="diff.project_id"
|
||||
class="grid grid-cols-[auto_1fr_1fr_1fr] items-center min-h-10 h-10 gap-2"
|
||||
>
|
||||
<div class="flex flex-col justify-between items-center">
|
||||
<div class="w-[1px] h-2"></div>
|
||||
<PlusIcon v-if="diff.type === 'added'" />
|
||||
<MinusIcon v-else-if="diff.type === 'removed'" />
|
||||
<RefreshCwIcon v-else />
|
||||
<div class="bg-surface-5 w-[1px] h-2 relative top-1"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 col-span-2">
|
||||
<span class="text-sm">{{ formatMessage(diffTypeMessages[diff.type]) }}</span>
|
||||
<span
|
||||
v-if="diff.project"
|
||||
v-tooltip="diff.project.title"
|
||||
class="text-sm text-contrast font-medium truncate"
|
||||
>
|
||||
{{ diff.project.title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="getFilename(diff.newVersion) || getFilename(diff.currentVersion)"
|
||||
v-tooltip="getFilename(diff.newVersion) || getFilename(diff.currentVersion)"
|
||||
class="text-xs truncate text-right"
|
||||
>
|
||||
{{ getFilename(diff.newVersion) || getFilename(diff.currentVersion) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-between gap-2">
|
||||
<ButtonStyled color="red" type="transparent">
|
||||
<button @click="handleReport">
|
||||
<ReportIcon />
|
||||
{{ formatMessage(commonMessages.reportButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="handleDecline">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleUpdate">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(commonMessages.updateButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
DownloadIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ReportIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { get_project, get_project_many, get_version_many } from '@/helpers/cache.js'
|
||||
import { update_managed_modrinth_version } from '@/helpers/profile'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
type Dependency = Labrinth.Versions.v3.Dependency
|
||||
type Version = Labrinth.Versions.v2.Version
|
||||
|
||||
interface BaseDiff {
|
||||
project_id: string
|
||||
project?: {
|
||||
title: string
|
||||
icon_url?: string
|
||||
slug: string
|
||||
}
|
||||
currentVersionId?: string
|
||||
newVersionId?: string
|
||||
currentVersion?: Version
|
||||
newVersion?: Version
|
||||
}
|
||||
interface AddedDiff extends BaseDiff {
|
||||
type: 'added'
|
||||
newVersionId: string
|
||||
}
|
||||
interface RemovedDiff extends BaseDiff {
|
||||
type: 'removed'
|
||||
}
|
||||
interface UpdatedDiff extends BaseDiff {
|
||||
type: 'updated'
|
||||
currentVersionId: string
|
||||
newVersionId: string
|
||||
}
|
||||
type DependencyDiff = AddedDiff | RemovedDiff | UpdatedDiff
|
||||
|
||||
type ProjectInfo = {
|
||||
id: string
|
||||
title: string
|
||||
icon_url?: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
const { instance } = defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const diffs = ref<DependencyDiff[]>([])
|
||||
const latestVersionId = ref<string | null>(null)
|
||||
const latestVersion = ref<Version | null>(null)
|
||||
|
||||
const removedCount = computed(() => diffs.value.filter((d) => d.type === 'removed').length)
|
||||
const addedCount = computed(() => diffs.value.filter((d) => d.type === 'added').length)
|
||||
const updatedCount = computed(() => diffs.value.filter((d) => d.type === 'updated').length)
|
||||
const publishedDate = computed(() =>
|
||||
latestVersion.value?.date_published ? new Date(latestVersion.value.date_published) : null,
|
||||
)
|
||||
|
||||
function getFilename(version?: Version): string | undefined {
|
||||
return version?.files.find((f) => f.primary)?.filename
|
||||
}
|
||||
|
||||
async function computeDependencyDiffs(
|
||||
currentDeps: Dependency[],
|
||||
latestDeps: Dependency[],
|
||||
): Promise<DependencyDiff[]> {
|
||||
const currentByProject = new Map<string, Dependency>(
|
||||
currentDeps.map((d) => [d.project_id || '', d]),
|
||||
)
|
||||
const latestByProject = new Map<string, Dependency>(
|
||||
latestDeps.map((d) => [d.project_id || '', d]),
|
||||
)
|
||||
|
||||
const diffs: DependencyDiff[] = []
|
||||
|
||||
// Find added and updated dependencies
|
||||
latestByProject.forEach((latestDep, projectId) => {
|
||||
if (!projectId) return
|
||||
const currentDep = currentByProject.get(projectId)
|
||||
if (!currentDep && latestDep.version_id) {
|
||||
diffs.push({ type: 'added', project_id: projectId, newVersionId: latestDep.version_id })
|
||||
} else if (
|
||||
currentDep?.version_id &&
|
||||
latestDep?.version_id &&
|
||||
currentDep?.version_id !== latestDep.version_id
|
||||
) {
|
||||
diffs.push({
|
||||
type: 'updated',
|
||||
project_id: projectId,
|
||||
currentVersionId: currentDep.version_id,
|
||||
newVersionId: latestDep.version_id,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Find removed dependencies
|
||||
currentByProject.forEach((currentDep, projectId) => {
|
||||
if (!projectId) return
|
||||
if (!latestByProject.has(projectId)) {
|
||||
diffs.push({
|
||||
type: 'removed',
|
||||
project_id: projectId,
|
||||
currentVersionId: currentDep.version_id,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch projects and versions of diffs
|
||||
const allProjectIds = [...new Set(diffs.map((d) => d.project_id).filter(Boolean))]
|
||||
const allVersionIds = [
|
||||
...new Set(
|
||||
[...diffs.map((d) => d.newVersionId), ...diffs.map((d) => d.currentVersionId)].filter(
|
||||
Boolean,
|
||||
),
|
||||
),
|
||||
] as string[]
|
||||
const [projects, versions] = await Promise.all([
|
||||
get_project_many(allProjectIds, 'must_revalidate'),
|
||||
get_version_many(allVersionIds, 'must_revalidate'),
|
||||
])
|
||||
|
||||
const projectMap = new Map<string, ProjectInfo>(projects.map((p: ProjectInfo) => [p.id, p]))
|
||||
const versionMap = new Map<string, Version>(versions.map((v: Version) => [v.id, v]))
|
||||
|
||||
return diffs
|
||||
.map((diff) => {
|
||||
const project = projectMap.get(diff.project_id)
|
||||
return {
|
||||
...diff,
|
||||
project: project
|
||||
? { title: project.title, icon_url: project.icon_url, slug: project.slug }
|
||||
: undefined,
|
||||
currentVersion: diff.currentVersionId ? versionMap.get(diff.currentVersionId) : undefined,
|
||||
newVersion: diff.newVersionId ? versionMap.get(diff.newVersionId) : undefined,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const typeOrder = { removed: 0, added: 1, updated: 2 }
|
||||
const typeCompare = typeOrder[a.type] - typeOrder[b.type]
|
||||
if (typeCompare !== 0) return typeCompare
|
||||
|
||||
const aDate = a.newVersion?.date_published || a.currentVersion?.date_published || ''
|
||||
const bDate = b.newVersion?.date_published || b.currentVersion?.date_published || ''
|
||||
return dayjs(bDate).valueOf() - dayjs(aDate).valueOf()
|
||||
})
|
||||
}
|
||||
|
||||
async function checkUpdateAvailable(instance: GameInstance): Promise<DependencyDiff[] | null> {
|
||||
if (!instance.linked_data) return null
|
||||
|
||||
try {
|
||||
const project = await get_project(instance.linked_data.project_id, 'must_revalidate')
|
||||
if (!project || !project.versions || project.versions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const versions = await get_version_many(project.versions, 'must_revalidate')
|
||||
const sortedVersions = versions.sort(
|
||||
(a: { date_published: string }, b: { date_published: string }) =>
|
||||
dayjs(b.date_published).valueOf() - dayjs(a.date_published).valueOf(),
|
||||
)
|
||||
|
||||
latestVersion.value = sortedVersions[0]
|
||||
latestVersionId.value = latestVersion.value?.id || null
|
||||
|
||||
const currentVersionId = instance.linked_data.version_id
|
||||
const currentVersion = versions.find((v: { id: string }) => v.id === currentVersionId)
|
||||
|
||||
// Compute dependency diffs between current and latest version
|
||||
if (currentVersion && latestVersion.value) {
|
||||
return await computeDependencyDiffs(
|
||||
currentVersion.dependencies || [],
|
||||
latestVersion.value.dependencies || [],
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error)
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => instance,
|
||||
async () => {
|
||||
const result = await checkUpdateAvailable(instance)
|
||||
diffs.value = result || []
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
async function handleUpdate() {
|
||||
hide()
|
||||
try {
|
||||
if (latestVersionId.value) {
|
||||
await update_managed_modrinth_version(instance.path, latestVersionId.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating instance:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleReport() {
|
||||
if (instance.linked_data?.project_id) {
|
||||
openUrl(`https://modrinth.com/report?item=project&itemID=${instance.linked_data.project_id}`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDecline() {
|
||||
hide()
|
||||
}
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
updateToPlay: {
|
||||
id: 'app.modal.update-to-play.header',
|
||||
defaultMessage: 'Update to play',
|
||||
},
|
||||
updateRequired: {
|
||||
id: 'app.modal.update-to-play.update-required',
|
||||
defaultMessage: 'Update required',
|
||||
},
|
||||
updateRequiredDescription: {
|
||||
id: 'app.modal.update-to-play.update-required-description',
|
||||
defaultMessage:
|
||||
'An update is required to play {name}. Please update to the latest version to launch the game.',
|
||||
},
|
||||
publishedDate: {
|
||||
id: 'app.modal.update-to-play.published-date',
|
||||
defaultMessage: '{date, date, long}',
|
||||
},
|
||||
removedCount: {
|
||||
id: 'app.modal.update-to-play.removed-count',
|
||||
defaultMessage: '{count} removed',
|
||||
},
|
||||
addedCount: {
|
||||
id: 'app.modal.update-to-play.added-count',
|
||||
defaultMessage: '{count} added',
|
||||
},
|
||||
updatedCount: {
|
||||
id: 'app.modal.update-to-play.updated-count',
|
||||
defaultMessage: '{count} updated',
|
||||
},
|
||||
})
|
||||
|
||||
const diffTypeMessages = defineMessages({
|
||||
added: {
|
||||
id: 'app.modal.update-to-play.diff-type.added',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
removed: {
|
||||
id: 'app.modal.update-to-play.diff-type.removed',
|
||||
defaultMessage: 'Removed',
|
||||
},
|
||||
updated: {
|
||||
id: 'app.modal.update-to-play.diff-type.updated',
|
||||
defaultMessage: 'Updated',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -5,6 +5,57 @@
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "Cannot reach authentication servers"
|
||||
},
|
||||
"app.modal.install-to-play.header": {
|
||||
"message": "Install to play"
|
||||
},
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Install"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, one {# mod} other {# mods}}"
|
||||
},
|
||||
"app.modal.install-to-play.server-requires-mods": {
|
||||
"message": "This server requires mods to play. Click install to set up the required files from Modrinth."
|
||||
},
|
||||
"app.modal.install-to-play.shared-by-today": {
|
||||
"message": "{name} shared this instance with you today."
|
||||
},
|
||||
"app.modal.install-to-play.shared-instance": {
|
||||
"message": "Shared instance"
|
||||
},
|
||||
"app.modal.install-to-play.shared-server-instance": {
|
||||
"message": "Shared server instance"
|
||||
},
|
||||
"app.modal.update-to-play.added-count": {
|
||||
"message": "{count} added"
|
||||
},
|
||||
"app.modal.update-to-play.diff-type.added": {
|
||||
"message": "Added"
|
||||
},
|
||||
"app.modal.update-to-play.diff-type.removed": {
|
||||
"message": "Removed"
|
||||
},
|
||||
"app.modal.update-to-play.diff-type.updated": {
|
||||
"message": "Updated"
|
||||
},
|
||||
"app.modal.update-to-play.header": {
|
||||
"message": "Update to play"
|
||||
},
|
||||
"app.modal.update-to-play.published-date": {
|
||||
"message": "{date, date, long}"
|
||||
},
|
||||
"app.modal.update-to-play.removed-count": {
|
||||
"message": "{count} removed"
|
||||
},
|
||||
"app.modal.update-to-play.update-required": {
|
||||
"message": "Update required"
|
||||
},
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "An update is required to play {name}. Please update to the latest version to launch the game."
|
||||
},
|
||||
"app.modal.update-to-play.updated-count": {
|
||||
"message": "{count} updated"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Developer mode enabled."
|
||||
},
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
>
|
||||
<ExportModal ref="exportModal" :instance="instance" />
|
||||
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
|
||||
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
|
||||
<ButtonStyled v-if="themeStore.featureFlags.server_project_qa">
|
||||
<button @click="updateToPlayModal.show()">Update to play modal</button>
|
||||
</ButtonStyled>
|
||||
<ContentPageHeader>
|
||||
<template #icon>
|
||||
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
|
||||
@@ -198,6 +202,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ExportModal from '@/components/ui/ExportModal.vue'
|
||||
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import NavTabs from '@/components/ui/NavTabs.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project, get_version_many } from '@/helpers/cache.js'
|
||||
@@ -229,6 +234,7 @@ const instance = ref()
|
||||
const modrinthVersions = ref([])
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
const updateToPlayModal = ref()
|
||||
|
||||
async function fetchInstance() {
|
||||
instance.value = await get(route.params.id).catch(handleError)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<InstallToPlayModal ref="installToPlayModal" :project="data" />
|
||||
<Teleport to="#sidebar-teleport-target">
|
||||
<ProjectSidebarCompatibility
|
||||
:project="data"
|
||||
@@ -23,6 +24,9 @@
|
||||
/>
|
||||
</Teleport>
|
||||
<div class="flex flex-col gap-4 p-6">
|
||||
<ButtonStyled v-if="themeStore.featureFlags.server_project_qa">
|
||||
<button @click="installToPlayModal.show()">Install to play modal</button>
|
||||
</ButtonStyled>
|
||||
<InstanceIndicator v-if="instance" :instance="instance" />
|
||||
<template v-if="data">
|
||||
<Teleport
|
||||
@@ -159,6 +163,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
|
||||
import NavTabs from '@/components/ui/NavTabs.vue'
|
||||
import { get_project, get_team, get_version_many } from '@/helpers/cache.js'
|
||||
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
|
||||
@@ -186,6 +191,8 @@ const instanceProjects = ref(null)
|
||||
const installed = ref(false)
|
||||
const installedVersion = ref(null)
|
||||
|
||||
const installToPlayModal = ref()
|
||||
|
||||
const instanceFilters = computed(() => {
|
||||
if (!instance.value) {
|
||||
return {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,10 +38,10 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
||||
async fn main() -> theseus::Result<()> {
|
||||
println!("Starting.");
|
||||
|
||||
let _log_guard = theseus::start_logger();
|
||||
let _log_guard = theseus::start_logger("ModrinthApp");
|
||||
|
||||
// Initialize state
|
||||
State::init().await?;
|
||||
State::init("ModrinthApp".to_owned()).await?;
|
||||
|
||||
let worlds = get_recent_worlds(4, EnumSet::all()).await?;
|
||||
for world in worlds {
|
||||
|
||||
@@ -29,6 +29,9 @@ pub async fn settings_set(settings: Settings) -> Result<()> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_directory_change() -> Result<()> {
|
||||
settings::cancel_directory_change().await?;
|
||||
let state = State::get().await?;
|
||||
let identifier = &state.app_identifier;
|
||||
|
||||
settings::cancel_directory_change(identifier).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -107,10 +107,12 @@ pub async fn open_path<R: Runtime>(app: tauri::AppHandle<R>, path: PathBuf) {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn show_launcher_logs_folder<R: Runtime>(app: tauri::AppHandle<R>) {
|
||||
let path = DirectoryInfo::launcher_logs_dir().unwrap_or_default();
|
||||
// failure to get folder just opens filesystem
|
||||
// (ie: if in debug mode only and launcher_logs never created)
|
||||
open_path(app, path).await;
|
||||
if let Some(d) = DirectoryInfo::global_handle_if_ready() {
|
||||
let path = d.launcher_logs_dir().unwrap_or_default();
|
||||
// failure to get folder just opens filesystem
|
||||
// (ie: if in debug mode only and launcher_logs never created)
|
||||
open_path(app, path).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Get opening command
|
||||
|
||||
@@ -28,7 +28,7 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
|
||||
theseus::EventState::init(app.clone()).await?;
|
||||
|
||||
tracing::info!("Initializing app state...");
|
||||
State::init().await?;
|
||||
State::init(app.config().identifier.clone()).await?;
|
||||
|
||||
let state = State::get().await?;
|
||||
app.asset_protocol_scope()
|
||||
@@ -110,7 +110,10 @@ fn main() {
|
||||
RUST_LOG="theseus=trace" {run command}
|
||||
|
||||
*/
|
||||
let _log_guard = theseus::start_logger();
|
||||
|
||||
let tauri_context = tauri::generate_context!();
|
||||
|
||||
let _log_guard = theseus::start_logger(&tauri_context.config().identifier);
|
||||
|
||||
tracing::info!("Initialized tracing subscriber. Loading Modrinth App!");
|
||||
|
||||
@@ -243,7 +246,7 @@ fn main() {
|
||||
]);
|
||||
|
||||
tracing::info!("Initializing app...");
|
||||
let app = builder.build(tauri::generate_context!());
|
||||
let app = builder.build(tauri_context);
|
||||
|
||||
match app {
|
||||
Ok(app) => {
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
class="text-muted flex flex-col gap-2 rounded-lg border border-divider bg-button-bg p-4"
|
||||
class="text-muted flex flex-col gap-2 rounded-lg border border-surface-5 bg-button-bg p-4"
|
||||
>
|
||||
<span>Hi {user.name},</span>
|
||||
<div class="textarea-wrapper">
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<div class="chart-controls">
|
||||
<h2>
|
||||
<span class="label__title">
|
||||
{{ formatCategoryHeader(selectedChart) }}
|
||||
{{ capitalizeString(selectedChart) }}
|
||||
</span>
|
||||
<span class="label__subtitle">
|
||||
{{ formattedCategorySubtitle }}
|
||||
@@ -311,7 +311,7 @@
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, PaletteIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import { Button, Card, DropdownSelect } from '@modrinth/ui'
|
||||
import { formatCategoryHeader, formatMoney, formatNumber } from '@modrinth/utils'
|
||||
import { capitalizeString, formatMoney, formatNumber } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
{{ formatMoney(result?.fee || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="border-b-1 h-0 w-full rounded-full border-b border-solid border-divider" />
|
||||
<div class="border-b-1 h-0 w-full rounded-full border-b border-solid border-surface-5" />
|
||||
<div
|
||||
class="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-0"
|
||||
>
|
||||
|
||||
+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">
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
Platform
|
||||
</slot>
|
||||
<template #option="{ option }">
|
||||
{{ formatCategory(option) }}
|
||||
{{ formatLoader(formatMessage, String(option)) }}
|
||||
</template>
|
||||
<template v-if="hasAnyUnsupportedPlatforms" #footer>
|
||||
<Checkbox
|
||||
@@ -58,12 +58,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FilterIcon } from '@modrinth/assets'
|
||||
import { formatLoader, useVIntl } from '@modrinth/ui'
|
||||
import Checkbox from '@modrinth/ui/src/components/base/Checkbox.vue'
|
||||
import ManySelect from '@modrinth/ui/src/components/base/ManySelect.vue'
|
||||
import { formatCategory, type GameVersionTag, type Version } from '@modrinth/utils'
|
||||
import type { GameVersionTag, Version } from '@modrinth/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
export type ListedGameVersion = {
|
||||
name: string
|
||||
release: boolean
|
||||
@@ -119,7 +122,7 @@ const hasOnlyUnsupportedPlatforms = computed(() => {
|
||||
|
||||
const showSupportedPlatformsOnly = ref(true)
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const filterOptions = computed<Record<'gameVersion' | 'platform', string[]>>(() => {
|
||||
const filters: Record<'gameVersion' | 'platform', string[]> = {
|
||||
gameVersion: [],
|
||||
platform: [],
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
v-if="isOpen"
|
||||
ref="menuRef"
|
||||
data-pyro-telepopover-root
|
||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-divider bg-bg-raised p-2 shadow-lg"
|
||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
|
||||
@@ -323,6 +323,57 @@ export const scopeMessages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
export const scopeCategoryMessages = defineMessages({
|
||||
categoryUserAccount: {
|
||||
id: 'scopes.category.user-account',
|
||||
defaultMessage: 'User account',
|
||||
},
|
||||
categoryProjects: {
|
||||
id: 'scopes.category.projects',
|
||||
defaultMessage: 'Projects',
|
||||
},
|
||||
categoryVersions: {
|
||||
id: 'scopes.category.versions',
|
||||
defaultMessage: 'Versions',
|
||||
},
|
||||
categoryCollections: {
|
||||
id: 'scopes.category.collections',
|
||||
defaultMessage: 'Collections',
|
||||
},
|
||||
categoryOrganizations: {
|
||||
id: 'scopes.category.organizations',
|
||||
defaultMessage: 'Organizations',
|
||||
},
|
||||
categoryReports: {
|
||||
id: 'scopes.category.reports',
|
||||
defaultMessage: 'Reports',
|
||||
},
|
||||
categoryThreads: {
|
||||
id: 'scopes.category.threads',
|
||||
defaultMessage: 'Threads',
|
||||
},
|
||||
categoryPats: {
|
||||
id: 'scopes.category.pats',
|
||||
defaultMessage: 'PATs',
|
||||
},
|
||||
categorySessions: {
|
||||
id: 'scopes.category.sessions',
|
||||
defaultMessage: 'Sessions',
|
||||
},
|
||||
categoryNotifications: {
|
||||
id: 'scopes.category.notifications',
|
||||
defaultMessage: 'Notifications',
|
||||
},
|
||||
categoryPayouts: {
|
||||
id: 'scopes.category.payouts',
|
||||
defaultMessage: 'Payouts',
|
||||
},
|
||||
categoryAnalytics: {
|
||||
id: 'scopes.category.analytics',
|
||||
defaultMessage: 'Analytics',
|
||||
},
|
||||
})
|
||||
|
||||
const scopeDefinitions = [
|
||||
{
|
||||
id: 'USER_READ_EMAIL',
|
||||
|
||||
@@ -14,6 +14,8 @@ export function createMultiFileDetectors(
|
||||
return {
|
||||
// Legacy texture pack (pre-1.6.1)
|
||||
legacyTexturePack: async (zip: JSZip): Promise<InferredVersionInfo | null> => {
|
||||
if (!rawFile.name.endsWith('.zip')) return null
|
||||
|
||||
const packTxt = zip.file('pack.txt')
|
||||
if (!packTxt) return null
|
||||
|
||||
|
||||
@@ -2364,10 +2364,10 @@
|
||||
"message": "You've already reported {title}"
|
||||
},
|
||||
"report.already-reported-description": {
|
||||
"message": "You have an open report for this {item} already. You can add more details to your report if you have more information to add."
|
||||
"message": "You have an open report for this {item, select, project {project} version {version} user {user} other {content}} already. You can add more details to your report if you have more information to add."
|
||||
},
|
||||
"report.back-to-item": {
|
||||
"message": "Back to {item}"
|
||||
"message": "Back to {item, select, project {project} version {version} user {user} other {content}}"
|
||||
},
|
||||
"report.body.description": {
|
||||
"message": "Include links and images if possible and relevant. Empty or insufficient reports will be closed and ignored."
|
||||
@@ -2376,10 +2376,10 @@
|
||||
"message": "Please provide additional context about your report"
|
||||
},
|
||||
"report.checking": {
|
||||
"message": "Checking {item}..."
|
||||
"message": "Checking {item, select, project {project} version {version} user {user} other {content}}..."
|
||||
},
|
||||
"report.could-not-find": {
|
||||
"message": "Could not find {item}"
|
||||
"message": "Could not find {item, select, project {project} version {version} user {user} other {content}}"
|
||||
},
|
||||
"report.for.violation": {
|
||||
"message": "Violation of Modrinth <rules-link>Rules</rules-link> or <terms-link>Terms of Use</terms-link>"
|
||||
@@ -2421,13 +2421,13 @@
|
||||
"message": "Please report:"
|
||||
},
|
||||
"report.question.content-id": {
|
||||
"message": "What is the ID of the {item}?"
|
||||
"message": "What is the ID of the {item, select, project {project} version {version} user {user} other {content}}?"
|
||||
},
|
||||
"report.question.content-type": {
|
||||
"message": "What type of content are you reporting?"
|
||||
},
|
||||
"report.question.report-reason": {
|
||||
"message": "Which of Modrinth's rules is this {item} violating?"
|
||||
"message": "Which of Modrinth's rules is this {item, select, project {project} version {version} user {user} other {content}} violating?"
|
||||
},
|
||||
"report.report-content": {
|
||||
"message": "Report content to moderators"
|
||||
@@ -2444,6 +2444,42 @@
|
||||
"scopes.analytics.label": {
|
||||
"message": "Read analytics"
|
||||
},
|
||||
"scopes.category.analytics": {
|
||||
"message": "Analytics"
|
||||
},
|
||||
"scopes.category.collections": {
|
||||
"message": "Collections"
|
||||
},
|
||||
"scopes.category.notifications": {
|
||||
"message": "Notifications"
|
||||
},
|
||||
"scopes.category.organizations": {
|
||||
"message": "Organizations"
|
||||
},
|
||||
"scopes.category.pats": {
|
||||
"message": "PATs"
|
||||
},
|
||||
"scopes.category.payouts": {
|
||||
"message": "Payouts"
|
||||
},
|
||||
"scopes.category.projects": {
|
||||
"message": "Projects"
|
||||
},
|
||||
"scopes.category.reports": {
|
||||
"message": "Reports"
|
||||
},
|
||||
"scopes.category.sessions": {
|
||||
"message": "Sessions"
|
||||
},
|
||||
"scopes.category.threads": {
|
||||
"message": "Threads"
|
||||
},
|
||||
"scopes.category.user-account": {
|
||||
"message": "User account"
|
||||
},
|
||||
"scopes.category.versions": {
|
||||
"message": "Versions"
|
||||
},
|
||||
"scopes.collectionCreate.description": {
|
||||
"message": "Create collections"
|
||||
},
|
||||
@@ -2741,6 +2777,102 @@
|
||||
"servers.plan.small.name": {
|
||||
"message": "Small"
|
||||
},
|
||||
"settings.applications.about": {
|
||||
"message": "About"
|
||||
},
|
||||
"settings.applications.button.add-more": {
|
||||
"message": "Add more"
|
||||
},
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Add a redirect uri"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Cancel"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Create app"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Delete"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Edit"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "New application"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Save changes"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Upload icon"
|
||||
},
|
||||
"settings.applications.client-id": {
|
||||
"message": "Client ID"
|
||||
},
|
||||
"settings.applications.client-secret": {
|
||||
"message": "Client secret"
|
||||
},
|
||||
"settings.applications.created-on": {
|
||||
"message": "Created on {date}"
|
||||
},
|
||||
"settings.applications.delete.confirm.button": {
|
||||
"message": "Delete this application"
|
||||
},
|
||||
"settings.applications.delete.confirm.description": {
|
||||
"message": "This will permanently delete this application and revoke all access tokens. (forever!)"
|
||||
},
|
||||
"settings.applications.delete.confirm.title": {
|
||||
"message": "Are you sure you want to delete this application?"
|
||||
},
|
||||
"settings.applications.description.intro": {
|
||||
"message": "Applications can be used to authenticate Modrinth's users with your products. For more information, see <docs-link>Modrinth's API documentation</docs-link>."
|
||||
},
|
||||
"settings.applications.field.description": {
|
||||
"message": "Description"
|
||||
},
|
||||
"settings.applications.field.description.placeholder": {
|
||||
"message": "Enter the application's description..."
|
||||
},
|
||||
"settings.applications.field.icon": {
|
||||
"message": "Icon"
|
||||
},
|
||||
"settings.applications.field.name": {
|
||||
"message": "Name"
|
||||
},
|
||||
"settings.applications.field.name.placeholder": {
|
||||
"message": "Enter the application's name..."
|
||||
},
|
||||
"settings.applications.field.redirect-uri.placeholder": {
|
||||
"message": "https://example.com/auth/callback"
|
||||
},
|
||||
"settings.applications.field.redirect-uris": {
|
||||
"message": "Redirect uris"
|
||||
},
|
||||
"settings.applications.field.scopes": {
|
||||
"message": "Scopes"
|
||||
},
|
||||
"settings.applications.field.url": {
|
||||
"message": "URL"
|
||||
},
|
||||
"settings.applications.field.url.placeholder": {
|
||||
"message": "https://example.com"
|
||||
},
|
||||
"settings.applications.modal.header": {
|
||||
"message": "Application information"
|
||||
},
|
||||
"settings.applications.notification.error.title": {
|
||||
"message": "An error occurred"
|
||||
},
|
||||
"settings.applications.notification.icon-updated.description": {
|
||||
"message": "Your application icon has been updated."
|
||||
},
|
||||
"settings.applications.notification.icon-updated.title": {
|
||||
"message": "Icon updated"
|
||||
},
|
||||
"settings.applications.secret.disclaimer": {
|
||||
"message": "Save your secret now, it will be hidden after you leave this page!"
|
||||
},
|
||||
"settings.billing.modal.cancel.action": {
|
||||
"message": "Cancel subscription"
|
||||
},
|
||||
|
||||
@@ -499,9 +499,26 @@
|
||||
</div>
|
||||
</template>
|
||||
<div class="input-group">
|
||||
<!--
|
||||
if we save changes and update an org member which:
|
||||
- is not currently overridden (!allOrgMembers[index].oldOverride)
|
||||
- and we're not changing them to be overridden (!allOrgMembers[index].override)
|
||||
|
||||
then we end up editing an org member which, in the backend, doesn't exist.
|
||||
the api doesn't let us do that, we can only do:
|
||||
- !override -> override: POST member
|
||||
- override -> !override: DELETE member
|
||||
- override -> override: PATCH member
|
||||
- !override -> !override: do nothing
|
||||
|
||||
we don't allow clicking the button in that last case.
|
||||
-->
|
||||
<button
|
||||
class="iconified-button brand-button"
|
||||
:disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
|
||||
:disabled="
|
||||
(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
|
||||
(!allOrgMembers[index].oldOverride && !allOrgMembers[index].override)
|
||||
"
|
||||
@click="updateOrgMember(index)"
|
||||
>
|
||||
<SaveIcon />
|
||||
@@ -565,22 +582,21 @@ function initMembers() {
|
||||
|
||||
const selectedMembersForOrg = orgMembers.map((partialOrgMember) => {
|
||||
const foundMember = allMembers.value.find((tM) => tM.user.id === partialOrgMember.user.id)
|
||||
const returnVal = foundMember ?? partialOrgMember
|
||||
|
||||
// If replacing a partial with a full member, we need to mark as such.
|
||||
returnVal.override = !!foundMember
|
||||
returnVal.oldOverride = !!foundMember
|
||||
|
||||
returnVal.is_owner = partialOrgMember.is_owner
|
||||
|
||||
return returnVal
|
||||
const base = foundMember ?? partialOrgMember
|
||||
return {
|
||||
...base,
|
||||
override: !!foundMember,
|
||||
oldOverride: !!foundMember,
|
||||
is_owner: partialOrgMember.is_owner,
|
||||
}
|
||||
})
|
||||
|
||||
allOrgMembers.value = selectedMembersForOrg
|
||||
|
||||
allTeamMembers.value = allMembers.value.filter(
|
||||
(x) => !selectedMembersForOrg.some((y) => y.user.id === x.user.id),
|
||||
)
|
||||
allTeamMembers.value = allMembers.value
|
||||
.filter((x) => !selectedMembersForOrg.some((y) => y.user.id === x.user.id))
|
||||
.map((x) => ({ ...x }))
|
||||
}
|
||||
|
||||
watch([allMembers, organization, project, currentMember], initMembers)
|
||||
@@ -688,6 +704,11 @@ const removeTeamMember = async (index) => {
|
||||
},
|
||||
)
|
||||
await updateMembers()
|
||||
addNotification({
|
||||
title: 'Member removed',
|
||||
text: "Your project's member has been removed.",
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
@@ -748,6 +769,11 @@ const transferOwnership = async (index) => {
|
||||
user_id: allTeamMembers.value[index].user.id,
|
||||
},
|
||||
})
|
||||
addNotification({
|
||||
title: 'Member ownership transferred',
|
||||
text: `${allTeamMembers.value[index].user.username} is now the owner of the project.`,
|
||||
type: 'success',
|
||||
})
|
||||
await updateMembers()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
@@ -795,6 +821,11 @@ async function updateOrgMember(index) {
|
||||
)
|
||||
}
|
||||
await updateMembers()
|
||||
addNotification({
|
||||
title: 'Member(s) updated',
|
||||
text: "Your project's member(s) has been updated.",
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<template v-for="header in Object.keys(categoryLists)" :key="`categories-${header}`">
|
||||
<div class="label mb-3">
|
||||
<h4>
|
||||
<span class="label__title">{{ formatCategoryHeader(header) }}</span>
|
||||
<span class="label__title">{{ formatCategoryHeader(formatMessage, header) }}</span>
|
||||
</h4>
|
||||
<span class="label__description">
|
||||
<template v-if="header === 'categories'">
|
||||
@@ -136,13 +136,14 @@ import { getCategoryIcon, StarIcon, TriangleAlertIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Checkbox,
|
||||
formatCategory,
|
||||
formatCategoryHeader,
|
||||
FormattedTag,
|
||||
injectProjectPageContext,
|
||||
UnsavedChangesPopup,
|
||||
useSavable,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { formatCategoryHeader, formatProjectType, sortedCategories } from '@modrinth/utils'
|
||||
import { formatProjectType, sortedCategories } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Category {
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
<template v-else> Unknown product </template>
|
||||
</span>
|
||||
<div class="mb-4 mt-2 flex w-full items-center gap-1 text-sm text-secondary">
|
||||
{{ formatCategory(subscription.interval) }} ⋅ {{ subscription.status }} ⋅
|
||||
{{ capitalizeString(subscription.interval) }} ⋅ {{ subscription.status }} ⋅
|
||||
{{ dayjs(subscription.created).format('MMMM D, YYYY [at] h:mma') }} ({{
|
||||
formatRelativeTime(subscription.created)
|
||||
}})
|
||||
@@ -334,7 +334,7 @@ import {
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { formatCategory, formatPrice } from '@modrinth/utils'
|
||||
import { capitalizeString, formatPrice } from '@modrinth/utils'
|
||||
import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
|
||||
@@ -22,9 +22,13 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HCaptcha ref="captcha" v-model="token" />
|
||||
<HCaptcha v-if="globals?.captcha_enabled" ref="captcha" v-model="token" />
|
||||
|
||||
<button class="btn btn-primary centered-btn" :disabled="!token" @click="recovery">
|
||||
<button
|
||||
class="btn btn-primary centered-btn"
|
||||
:disabled="globals?.captcha_enabled ? !token : false"
|
||||
@click="recovery"
|
||||
>
|
||||
<SendIcon /> {{ formatMessage(methodChoiceMessages.action) }}
|
||||
</button>
|
||||
</template>
|
||||
@@ -158,6 +162,15 @@ if (route.query.flow) {
|
||||
|
||||
const captcha = ref()
|
||||
|
||||
const { data: globals } = await useAsyncData('auth-globals', async () => {
|
||||
try {
|
||||
return await useBaseFetch('globals', { internal: true })
|
||||
} catch (err) {
|
||||
console.error('Error fetching globals:', err)
|
||||
return { captcha_enabled: true }
|
||||
}
|
||||
})
|
||||
|
||||
const email = ref('')
|
||||
const token = ref('')
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,29 +2,33 @@
|
||||
<div class="universal-card">
|
||||
<ConfirmModal
|
||||
ref="modal_confirm"
|
||||
title="Are you sure you want to delete this application?"
|
||||
description="This will permanently delete this application and revoke all access tokens. (forever!)"
|
||||
proceed-label="Delete this application"
|
||||
:title="formatMessage(messages.deleteConfirmTitle)"
|
||||
:description="formatMessage(messages.deleteConfirmDescription)"
|
||||
:proceed-label="formatMessage(messages.deleteConfirmButton)"
|
||||
@proceed="removeApp(editingId)"
|
||||
/>
|
||||
<Modal ref="appModal" header="Application information">
|
||||
<Modal ref="appModal" :header="formatMessage(messages.modalHeader)">
|
||||
<div class="universal-modal">
|
||||
<label for="app-name"><span class="label__title">Name</span> </label>
|
||||
<label for="app-name"
|
||||
><span class="label__title">{{ formatMessage(messages.nameLabel) }}</span>
|
||||
</label>
|
||||
<input
|
||||
id="app-name"
|
||||
v-model="name"
|
||||
maxlength="2048"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter the application's name..."
|
||||
:placeholder="formatMessage(messages.namePlaceholder)"
|
||||
/>
|
||||
<label v-if="editingId" for="app-icon"><span class="label__title">Icon</span> </label>
|
||||
<label v-if="editingId" for="app-icon"
|
||||
><span class="label__title">{{ formatMessage(messages.iconLabel) }}</span>
|
||||
</label>
|
||||
<div v-if="editingId" class="icon-submission">
|
||||
<Avatar size="md" :src="icon" />
|
||||
<FileInput
|
||||
:max-size="262144"
|
||||
class="btn"
|
||||
prompt="Upload icon"
|
||||
:prompt="formatMessage(messages.uploadIcon)"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
@change="onImageSelection"
|
||||
>
|
||||
@@ -32,7 +36,7 @@
|
||||
</FileInput>
|
||||
</div>
|
||||
<label v-if="editingId" for="app-url">
|
||||
<span class="label__title">URL</span>
|
||||
<span class="label__title">{{ formatMessage(messages.urlLabel) }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="editingId"
|
||||
@@ -41,10 +45,10 @@
|
||||
maxlength="255"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
placeholder="https://example.com"
|
||||
:placeholder="formatMessage(messages.urlPlaceholder)"
|
||||
/>
|
||||
<label v-if="editingId" for="app-description">
|
||||
<span class="label__title">Description</span>
|
||||
<span class="label__title">{{ formatMessage(messages.descriptionLabel) }}</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-if="editingId"
|
||||
@@ -54,19 +58,33 @@
|
||||
maxlength="255"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter the application's description..."
|
||||
:placeholder="formatMessage(messages.descriptionPlaceholder)"
|
||||
/>
|
||||
<label for="app-scopes"><span class="label__title">Scopes</span> </label>
|
||||
<div id="app-scopes" class="checkboxes">
|
||||
<Checkbox
|
||||
v-for="scope in scopeList"
|
||||
:key="scope"
|
||||
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
|
||||
:model-value="hasScope(scopesVal, scope)"
|
||||
@update:model-value="() => (scopesVal = toggleScope(scopesVal, scope))"
|
||||
/>
|
||||
<label for="app-scopes"
|
||||
><span class="label__title">{{ formatMessage(messages.scopesLabel) }}</span>
|
||||
</label>
|
||||
<div
|
||||
id="app-scopes"
|
||||
class="scope-items mt-2 grid grid-cols-1 gap-x-6 gap-y-4 min-[600px]:grid-cols-2"
|
||||
>
|
||||
<div v-for="category in scopeCategories" :key="category.name" class="flex flex-col gap-2">
|
||||
<h4 class="m-0 border-b border-divider pb-1 text-base font-bold text-contrast">
|
||||
{{ category.name }}
|
||||
</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Checkbox
|
||||
v-for="scope in category.scopes"
|
||||
:key="scope"
|
||||
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
|
||||
:model-value="hasScope(scopesVal, scope)"
|
||||
@update:model-value="() => (scopesVal = toggleScope(scopesVal, scope))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label for="app-redirect-uris"><span class="label__title">Redirect uris</span> </label>
|
||||
<label for="app-redirect-uris" class="mt-4"
|
||||
><span class="label__title">{{ formatMessage(messages.redirectUrisLabel) }}</span>
|
||||
</label>
|
||||
<div class="uri-input-list">
|
||||
<div v-for="(_, index) in redirectUris" :key="index">
|
||||
<div class="input-group url-input-group-fixes">
|
||||
@@ -75,7 +93,7 @@
|
||||
maxlength="2048"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
placeholder="https://example.com/auth/callback"
|
||||
:placeholder="formatMessage(messages.redirectUriPlaceholder)"
|
||||
/>
|
||||
<Button v-if="index !== 0" icon-only @click="() => redirectUris.splice(index, 1)">
|
||||
<TrashIcon />
|
||||
@@ -86,13 +104,13 @@
|
||||
icon-only
|
||||
@click="() => redirectUris.push('')"
|
||||
>
|
||||
<PlusIcon /> Add more
|
||||
<PlusIcon /> {{ formatMessage(messages.addMore) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="redirectUris.length <= 0">
|
||||
<Button color="primary" icon-only @click="() => redirectUris.push('')">
|
||||
<PlusIcon /> Add a redirect uri
|
||||
<PlusIcon /> {{ formatMessage(messages.addRedirectUri) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,7 +118,7 @@
|
||||
<div class="submit-row input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.appModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</button>
|
||||
<button
|
||||
v-if="editingId"
|
||||
@@ -110,7 +128,7 @@
|
||||
@click="editApp"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
{{ formatMessage(messages.saveChanges) }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@@ -120,7 +138,7 @@
|
||||
@click="createApp"
|
||||
>
|
||||
<PlusIcon />
|
||||
Create App
|
||||
{{ formatMessage(messages.createApp) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,13 +162,17 @@
|
||||
}
|
||||
"
|
||||
>
|
||||
<PlusIcon /> New Application
|
||||
<PlusIcon /> {{ formatMessage(messages.newApplication) }}
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
Applications can be used to authenticate Modrinth's users with your products. For more
|
||||
information, see
|
||||
<a class="text-link" href="https://docs.modrinth.com">Modrinth's API documentation</a>.
|
||||
<IntlFormatted :message-id="messages.descriptionIntro">
|
||||
<template #docs-link="{ children }">
|
||||
<a class="text-link" href="https://docs.modrinth.com">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<div v-for="app in usersApps" :key="app.id" class="universal-card recessed token mt-4">
|
||||
<div class="token-info">
|
||||
@@ -158,25 +180,31 @@
|
||||
<Avatar size="sm" :src="app.icon_url" />
|
||||
<div>
|
||||
<h2 class="token-title">{{ app.name }}</h2>
|
||||
<div>Created on {{ new Date(app.created).toLocaleDateString() }}</div>
|
||||
<div>
|
||||
{{
|
||||
formatMessage(messages.createdOn, {
|
||||
date: new Date(app.created).toLocaleDateString(),
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="token-information">
|
||||
<span class="label__title">About</span>
|
||||
<span class="label__title">{{ formatMessage(messages.aboutLabel) }}</span>
|
||||
</label>
|
||||
<div class="token-content">
|
||||
<div>
|
||||
Client ID
|
||||
{{ formatMessage(messages.clientId) }}
|
||||
<CopyCode :text="app.id" />
|
||||
</div>
|
||||
<div v-if="!!clientCreatedInState(app.id)">
|
||||
<div>
|
||||
Client Secret
|
||||
{{ formatMessage(messages.clientSecret) }}
|
||||
<CopyCode :text="clientCreatedInState(app.id)?.client_secret" />
|
||||
</div>
|
||||
<div class="secret_disclaimer">
|
||||
<i> Save your secret now, it will be hidden after you leave this page! </i>
|
||||
<i>{{ formatMessage(messages.secretDisclaimer) }}</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,7 +224,7 @@
|
||||
"
|
||||
>
|
||||
<EditIcon />
|
||||
Edit
|
||||
{{ formatMessage(messages.edit) }}
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
@@ -209,7 +237,7 @@
|
||||
"
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete
|
||||
{{ formatMessage(messages.delete) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,8 +252,11 @@ import {
|
||||
commonSettingsMessages,
|
||||
ConfirmModal,
|
||||
CopyCode,
|
||||
defineMessages,
|
||||
FileInput,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
normalizeChildren,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
@@ -233,6 +264,7 @@ import Modal from '~/components/ui/Modal.vue'
|
||||
import {
|
||||
getScopeValue,
|
||||
hasScope,
|
||||
scopeCategoryMessages,
|
||||
scopeList,
|
||||
toggleScope,
|
||||
useScopes,
|
||||
@@ -249,8 +281,196 @@ useHead({
|
||||
title: 'Applications - Modrinth',
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
modalHeader: {
|
||||
id: 'settings.applications.modal.header',
|
||||
defaultMessage: 'Application information',
|
||||
},
|
||||
deleteConfirmTitle: {
|
||||
id: 'settings.applications.delete.confirm.title',
|
||||
defaultMessage: 'Are you sure you want to delete this application?',
|
||||
},
|
||||
deleteConfirmDescription: {
|
||||
id: 'settings.applications.delete.confirm.description',
|
||||
defaultMessage:
|
||||
'This will permanently delete this application and revoke all access tokens. (forever!)',
|
||||
},
|
||||
deleteConfirmButton: {
|
||||
id: 'settings.applications.delete.confirm.button',
|
||||
defaultMessage: 'Delete this application',
|
||||
},
|
||||
nameLabel: {
|
||||
id: 'settings.applications.field.name',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
namePlaceholder: {
|
||||
id: 'settings.applications.field.name.placeholder',
|
||||
defaultMessage: "Enter the application's name...",
|
||||
},
|
||||
iconLabel: {
|
||||
id: 'settings.applications.field.icon',
|
||||
defaultMessage: 'Icon',
|
||||
},
|
||||
uploadIcon: {
|
||||
id: 'settings.applications.button.upload-icon',
|
||||
defaultMessage: 'Upload icon',
|
||||
},
|
||||
urlLabel: {
|
||||
id: 'settings.applications.field.url',
|
||||
defaultMessage: 'URL',
|
||||
},
|
||||
urlPlaceholder: {
|
||||
id: 'settings.applications.field.url.placeholder',
|
||||
defaultMessage: 'https://example.com',
|
||||
},
|
||||
descriptionLabel: {
|
||||
id: 'settings.applications.field.description',
|
||||
defaultMessage: 'Description',
|
||||
},
|
||||
descriptionPlaceholder: {
|
||||
id: 'settings.applications.field.description.placeholder',
|
||||
defaultMessage: "Enter the application's description...",
|
||||
},
|
||||
scopesLabel: {
|
||||
id: 'settings.applications.field.scopes',
|
||||
defaultMessage: 'Scopes',
|
||||
},
|
||||
redirectUrisLabel: {
|
||||
id: 'settings.applications.field.redirect-uris',
|
||||
defaultMessage: 'Redirect uris',
|
||||
},
|
||||
redirectUriPlaceholder: {
|
||||
id: 'settings.applications.field.redirect-uri.placeholder',
|
||||
defaultMessage: 'https://example.com/auth/callback',
|
||||
},
|
||||
addMore: {
|
||||
id: 'settings.applications.button.add-more',
|
||||
defaultMessage: 'Add more',
|
||||
},
|
||||
addRedirectUri: {
|
||||
id: 'settings.applications.button.add-redirect-uri',
|
||||
defaultMessage: 'Add a redirect uri',
|
||||
},
|
||||
cancel: {
|
||||
id: 'settings.applications.button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
saveChanges: {
|
||||
id: 'settings.applications.button.save-changes',
|
||||
defaultMessage: 'Save changes',
|
||||
},
|
||||
createApp: {
|
||||
id: 'settings.applications.button.create',
|
||||
defaultMessage: 'Create app',
|
||||
},
|
||||
newApplication: {
|
||||
id: 'settings.applications.button.new',
|
||||
defaultMessage: 'New application',
|
||||
},
|
||||
descriptionIntro: {
|
||||
id: 'settings.applications.description.intro',
|
||||
defaultMessage:
|
||||
"Applications can be used to authenticate Modrinth's users with your products. For more information, see <docs-link>Modrinth's API documentation</docs-link>.",
|
||||
},
|
||||
aboutLabel: {
|
||||
id: 'settings.applications.about',
|
||||
defaultMessage: 'About',
|
||||
},
|
||||
clientId: {
|
||||
id: 'settings.applications.client-id',
|
||||
defaultMessage: 'Client ID',
|
||||
},
|
||||
clientSecret: {
|
||||
id: 'settings.applications.client-secret',
|
||||
defaultMessage: 'Client secret',
|
||||
},
|
||||
secretDisclaimer: {
|
||||
id: 'settings.applications.secret.disclaimer',
|
||||
defaultMessage: 'Save your secret now, it will be hidden after you leave this page!',
|
||||
},
|
||||
createdOn: {
|
||||
id: 'settings.applications.created-on',
|
||||
defaultMessage: 'Created on {date}',
|
||||
},
|
||||
edit: {
|
||||
id: 'settings.applications.button.edit',
|
||||
defaultMessage: 'Edit',
|
||||
},
|
||||
delete: {
|
||||
id: 'settings.applications.button.delete',
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
iconUpdatedTitle: {
|
||||
id: 'settings.applications.notification.icon-updated.title',
|
||||
defaultMessage: 'Icon updated',
|
||||
},
|
||||
iconUpdatedDescription: {
|
||||
id: 'settings.applications.notification.icon-updated.description',
|
||||
defaultMessage: 'Your application icon has been updated.',
|
||||
},
|
||||
errorTitle: {
|
||||
id: 'settings.applications.notification.error.title',
|
||||
defaultMessage: 'An error occurred',
|
||||
},
|
||||
})
|
||||
|
||||
const { scopesToLabels } = useScopes()
|
||||
|
||||
const scopeCategories = computed(() => {
|
||||
return [
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryUserAccount),
|
||||
scopes: scopeList.filter((s) => s.startsWith('USER_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryProjects),
|
||||
scopes: scopeList.filter((s) => s.startsWith('PROJECT_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryVersions),
|
||||
scopes: scopeList.filter((s) => s.startsWith('VERSION_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryCollections),
|
||||
scopes: scopeList.filter((s) => s.startsWith('COLLECTION_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryOrganizations),
|
||||
scopes: scopeList.filter((s) => s.startsWith('ORGANIZATION_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryReports),
|
||||
scopes: scopeList.filter((s) => s.startsWith('REPORT_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryThreads),
|
||||
scopes: scopeList.filter((s) => s.startsWith('THREAD_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryPats),
|
||||
scopes: scopeList.filter((s) => s.startsWith('PAT_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categorySessions),
|
||||
scopes: scopeList.filter((s) => s.startsWith('SESSION_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryNotifications),
|
||||
scopes: scopeList.filter((s) => s.startsWith('NOTIFICATION_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryPayouts),
|
||||
scopes: scopeList.filter((s) => s.startsWith('PAYOUTS_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryAnalytics),
|
||||
scopes: scopeList.filter(
|
||||
(s) => s.startsWith('ANALYTICS') || s.startsWith('PERFORM_ANALYTICS'),
|
||||
),
|
||||
},
|
||||
].filter((c) => c.scopes.length > 0)
|
||||
})
|
||||
|
||||
const appModal = ref()
|
||||
|
||||
// Any apps created in the current state will be stored here
|
||||
@@ -347,8 +567,8 @@ async function onImageSelection(files) {
|
||||
}
|
||||
|
||||
addNotification({
|
||||
title: 'Icon updated',
|
||||
text: 'Your application icon has been updated.',
|
||||
title: formatMessage(messages.iconUpdatedTitle),
|
||||
text: formatMessage(messages.iconUpdatedDescription),
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
@@ -377,7 +597,7 @@ async function createApp() {
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
title: formatMessage(messages.errorTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
@@ -445,7 +665,7 @@ async function editApp() {
|
||||
appModal.value.hide()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
title: formatMessage(messages.errorTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
@@ -468,7 +688,7 @@ async function removeApp() {
|
||||
editingId.value = null
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
title: formatMessage(messages.errorTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
@@ -500,17 +720,10 @@ async function removeApp() {
|
||||
flex-basis: 24rem !important;
|
||||
}
|
||||
}
|
||||
.checkboxes {
|
||||
display: grid;
|
||||
column-gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 432px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.scope-items :deep(.checkbox-outer) {
|
||||
white-space: nowrap !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
.icon-submission {
|
||||
|
||||
@@ -29,16 +29,26 @@
|
||||
<label for="pat-scopes">
|
||||
<span class="label__title">{{ formatMessage(commonMessages.scopesLabel) }}</span>
|
||||
</label>
|
||||
<div id="pat-scopes" class="checkboxes">
|
||||
<Checkbox
|
||||
v-for="scope in scopeList"
|
||||
:key="scope"
|
||||
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
|
||||
:model-value="hasScope(scopesVal, scope)"
|
||||
@update:model-value="scopesVal = toggleScope(scopesVal, scope)"
|
||||
/>
|
||||
<div
|
||||
id="pat-scopes"
|
||||
class="scope-items mt-2 grid grid-cols-1 gap-x-6 gap-y-4 min-[600px]:grid-cols-2"
|
||||
>
|
||||
<div v-for="category in scopeCategories" :key="category.name" class="flex flex-col gap-2">
|
||||
<h4 class="m-0 border-b border-divider pb-1 text-base font-bold text-contrast">
|
||||
{{ category.name }}
|
||||
</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Checkbox
|
||||
v-for="scope in category.scopes"
|
||||
:key="scope"
|
||||
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
|
||||
:model-value="hasScope(scopesVal, scope)"
|
||||
@update:model-value="scopesVal = toggleScope(scopesVal, scope)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label for="pat-name">
|
||||
<label for="pat-name" class="mt-4">
|
||||
<span class="label__title">{{ formatMessage(createModalMessages.expiresLabel) }}</span>
|
||||
</label>
|
||||
<input id="pat-name" v-model="expires" type="date" />
|
||||
@@ -171,7 +181,7 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="token-actions ml-auto flex flex-col gap-2">
|
||||
<button
|
||||
class="iconified-button raised-button"
|
||||
@click="
|
||||
@@ -220,6 +230,7 @@ import Modal from '~/components/ui/Modal.vue'
|
||||
import {
|
||||
getScopeValue,
|
||||
hasScope,
|
||||
scopeCategoryMessages,
|
||||
scopeList,
|
||||
toggleScope,
|
||||
useScopes,
|
||||
@@ -338,6 +349,61 @@ const displayPats = computed(() => {
|
||||
return pats.value.toSorted((a, b) => new Date(b.created) - new Date(a.created))
|
||||
})
|
||||
|
||||
const scopeCategories = computed(() => {
|
||||
return [
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryUserAccount),
|
||||
scopes: scopeList.filter((s) => s.startsWith('USER_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryProjects),
|
||||
scopes: scopeList.filter((s) => s.startsWith('PROJECT_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryVersions),
|
||||
scopes: scopeList.filter((s) => s.startsWith('VERSION_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryCollections),
|
||||
scopes: scopeList.filter((s) => s.startsWith('COLLECTION_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryOrganizations),
|
||||
scopes: scopeList.filter((s) => s.startsWith('ORGANIZATION_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryReports),
|
||||
scopes: scopeList.filter((s) => s.startsWith('REPORT_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryThreads),
|
||||
scopes: scopeList.filter((s) => s.startsWith('THREAD_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryPats),
|
||||
scopes: scopeList.filter((s) => s.startsWith('PAT_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categorySessions),
|
||||
scopes: scopeList.filter((s) => s.startsWith('SESSION_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryNotifications),
|
||||
scopes: scopeList.filter((s) => s.startsWith('NOTIFICATION_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryPayouts),
|
||||
scopes: scopeList.filter((s) => s.startsWith('PAYOUTS_')),
|
||||
},
|
||||
{
|
||||
name: formatMessage(scopeCategoryMessages.categoryAnalytics),
|
||||
scopes: scopeList.filter(
|
||||
(s) => s.startsWith('ANALYTICS') || s.startsWith('PERFORM_ANALYTICS'),
|
||||
),
|
||||
},
|
||||
].filter((c) => c.scopes.length > 0)
|
||||
})
|
||||
|
||||
async function createPat() {
|
||||
startLoading()
|
||||
loading.value = true
|
||||
@@ -407,17 +473,9 @@ async function removePat(id) {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.checkboxes {
|
||||
display: grid;
|
||||
column-gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 432px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.scope-items :deep(.checkbox-outer) {
|
||||
white-space: nowrap !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
.token {
|
||||
@@ -428,10 +486,6 @@ async function removePat(id) {
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -651,15 +651,27 @@ export function createWithdrawContext(
|
||||
)
|
||||
|
||||
if (selectedMethod?.interval) {
|
||||
const userMax = Math.floor(maxWithdrawAmount.value * 100) / 100
|
||||
const userMaxUsd = Math.floor(maxWithdrawAmount.value * 100) / 100
|
||||
|
||||
const exchangeRate = selectedMethod.exchange_rate
|
||||
const isNonUsdCurrency =
|
||||
selectedMethod.currency_code &&
|
||||
selectedMethod.currency_code !== 'USD' &&
|
||||
exchangeRate &&
|
||||
exchangeRate > 0
|
||||
|
||||
const userMaxInLocalCurrency = isNonUsdCurrency ? userMaxUsd * exchangeRate : userMaxUsd
|
||||
|
||||
if (selectedMethod.interval.standard) {
|
||||
const { min, max } = selectedMethod.interval.standard
|
||||
const effectiveMax = Math.min(userMax, max)
|
||||
const effectiveMax = Math.min(userMaxInLocalCurrency, max)
|
||||
const effectiveMin = Math.min(min, effectiveMax)
|
||||
if (amount < effectiveMin || amount > effectiveMax) return false
|
||||
}
|
||||
if (selectedMethod.interval.fixed) {
|
||||
const validValues = selectedMethod.interval.fixed.values.filter((v) => v <= userMax)
|
||||
const validValues = selectedMethod.interval.fixed.values.filter(
|
||||
(v) => v <= userMaxInLocalCurrency,
|
||||
)
|
||||
if (!validValues.includes(amount)) return false
|
||||
}
|
||||
}
|
||||
@@ -817,6 +829,7 @@ export function createWithdrawContext(
|
||||
calculation: {
|
||||
amount: 0,
|
||||
fee: null,
|
||||
netUsd: null,
|
||||
exchangeRate: null,
|
||||
},
|
||||
providerData: {
|
||||
|
||||
@@ -289,12 +289,15 @@ video.unrulymedia.com, 586616193, RESELLER
|
||||
#PGAMSSP
|
||||
pgamssp.com, 642db2eba4eb3f6b34033505, DIRECT
|
||||
pubmatic.com, 162623, RESELLER, 5d62403b186f2ace
|
||||
pubmatic.com, 165708, RESELLER, 5d62403b186f2ace
|
||||
rubiconproject.com, 24852, RESELLER, 0bfd66d529a55807
|
||||
video.unrulymedia.com, 5921144960123684292, RESELLER
|
||||
onetag.com, 87f80e5d9d55274, RESELLER
|
||||
amxrtb.com, 105199776, RESELLER
|
||||
sharethrough.com, FhiWXM0L, RESELLER, d53b998a7bd4ecd2
|
||||
sharethrough.com, XeKuhSkz, RESELLER, d53b998a7bd4ecd2
|
||||
33across.com, 0015a00003ALsDfAAL, DIRECT, bbea06d9c4d2853c
|
||||
33across.com, 0015a00002egvRSAAY, RESELLER, bbea06d9c4d2853c
|
||||
admanmedia.com, 2209, RESELLER
|
||||
triplelift.com, 12456, RESELLER, 6c33edb13117fd86
|
||||
media.net, 8CU58PCO4, RESELLER
|
||||
@@ -302,6 +305,8 @@ lijit.com, 402418, RESELLER, fafdf38b16bf6b2b
|
||||
inmobi.com, 8b70afb78ac249ccbd71be4f75dbf09c, RESELLER, 83e75a7ae333ca9d
|
||||
smaato.com, 1100058906, RESELLER, 07bcf65f187117b4
|
||||
appnexus.com, 15427 , RESELLER, f5ab79cb980f11d1
|
||||
appnexus.com, 9684, RESELLER, f5ab79cb980f11d1
|
||||
lijit.com, 402418, RESELLER, fafdf38b16bf6b2b
|
||||
|
||||
#Pubmatic
|
||||
pubmatic.com, 160923, RESELLER, 5d62403b186f2ace
|
||||
|
||||
@@ -19,6 +19,7 @@ MEILISEARCH_WRITE_ADDRS=http://localhost:7700
|
||||
MEILISEARCH_KEY=modrinth
|
||||
|
||||
REDIS_URL=redis://labrinth-redis
|
||||
REDIS_MIN_CONNECTIONS=0
|
||||
REDIS_MAX_CONNECTIONS=10000
|
||||
|
||||
BIND_ADDR=0.0.0.0:8000
|
||||
|
||||
@@ -17,6 +17,8 @@ DATABASE_MAX_CONNECTIONS=16
|
||||
|
||||
MEILISEARCH_READ_ADDR=http://localhost:7700
|
||||
MEILISEARCH_WRITE_ADDRS=http://localhost:7700
|
||||
# 5 minutes in milliseconds
|
||||
SEARCH_OPERATION_TIMEOUT=300000
|
||||
|
||||
# # For a sharded Meilisearch setup (sharded-meilisearch docker compose profile)
|
||||
# MEILISEARCH_READ_ADDR=http://localhost:7710
|
||||
@@ -25,6 +27,7 @@ MEILISEARCH_WRITE_ADDRS=http://localhost:7700
|
||||
MEILISEARCH_KEY=modrinth
|
||||
|
||||
REDIS_URL=redis://localhost
|
||||
REDIS_MIN_CONNECTIONS=0
|
||||
REDIS_MAX_CONNECTIONS=10000
|
||||
|
||||
# Must bind to broadcast, not localhost, because some
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use ariadne::ids::base62_impl::{parse_base62, to_base62};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use dashmap::DashMap;
|
||||
use deadpool_redis::{Config, Runtime};
|
||||
use futures::TryStreamExt;
|
||||
use futures::future::Either;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use prometheus::{IntGauge, Registry};
|
||||
@@ -13,8 +14,9 @@ use std::collections::HashMap;
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::future::Future;
|
||||
use std::hash::Hash;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{Instrument, info_span};
|
||||
use tracing::{Instrument, info, info_span};
|
||||
use util::{cmd, redis_pipe};
|
||||
|
||||
pub mod util;
|
||||
@@ -25,21 +27,21 @@ const ACTUAL_EXPIRY: i64 = 60 * 30; // 30 minutes
|
||||
#[derive(Clone)]
|
||||
pub struct RedisPool {
|
||||
pub url: String,
|
||||
pub pool: util::InstrumentedPool,
|
||||
cache_list: DashMap<String, util::CacheSubscriber>,
|
||||
meta_namespace: String,
|
||||
pub pool: deadpool_redis::Pool,
|
||||
cache_list: Arc<DashMap<String, util::CacheSubscriber>>,
|
||||
meta_namespace: Arc<str>,
|
||||
}
|
||||
|
||||
pub struct RedisConnection {
|
||||
pub connection: deadpool_redis::Connection,
|
||||
meta_namespace: String,
|
||||
meta_namespace: Arc<str>,
|
||||
}
|
||||
|
||||
impl RedisPool {
|
||||
// initiate a new redis pool
|
||||
// testing pool uses a hashmap to mimic redis behaviour for very small data sizes (ie: tests)
|
||||
// PANICS: production pool will panic if redis url is not set
|
||||
pub fn new(meta_namespace: Option<String>) -> Self {
|
||||
pub fn new(meta_namespace: impl Into<Arc<str>>) -> Self {
|
||||
let wait_timeout =
|
||||
dotenvy::var("REDIS_WAIT_TIMEOUT_MS").ok().map_or_else(
|
||||
|| Duration::from_millis(15000),
|
||||
@@ -69,11 +71,35 @@ impl RedisPool {
|
||||
|
||||
let pool = RedisPool {
|
||||
url,
|
||||
pool: util::InstrumentedPool::new(pool),
|
||||
cache_list: DashMap::with_capacity(2048),
|
||||
meta_namespace: meta_namespace.unwrap_or("".to_string()),
|
||||
pool,
|
||||
cache_list: Arc::new(DashMap::with_capacity(2048)),
|
||||
meta_namespace: meta_namespace.into(),
|
||||
};
|
||||
|
||||
let redis_min_connections = dotenvy::var("REDIS_MIN_CONNECTIONS")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
let spawn_min_connections = (0..redis_min_connections)
|
||||
.map(|_| {
|
||||
let pool = pool.clone();
|
||||
tokio::spawn(async move { pool.pool.get().await })
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
tokio::spawn({
|
||||
let pool = pool.clone();
|
||||
async move {
|
||||
// collect the connections into a buffer while we're spawning them,
|
||||
// to make sure that we're not `get`ing any connections we previously took
|
||||
let _connections =
|
||||
spawn_min_connections.try_collect::<Vec<_>>().await;
|
||||
info!(
|
||||
pool_status = ?pool.pool.status(),
|
||||
"Finished getting {redis_min_connections} initial Redis connections"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let interval = Duration::from_secs(30);
|
||||
let max_age = Duration::from_secs(5 * 60); // 5 minutes
|
||||
let pool_ref = pool.clone();
|
||||
|
||||
@@ -2,7 +2,6 @@ use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use deadpool_redis::PoolError;
|
||||
use derive_more::{Deref, DerefMut};
|
||||
use redis::{FromRedisValue, RedisResult, ToRedisArgs};
|
||||
use tokio::sync::Notify;
|
||||
@@ -11,24 +10,6 @@ use tracing::{Instrument, info_span};
|
||||
|
||||
use crate::database::models::DatabaseError;
|
||||
|
||||
#[derive(Debug, Clone, Deref, DerefMut)]
|
||||
pub struct InstrumentedPool {
|
||||
inner: deadpool_redis::Pool,
|
||||
}
|
||||
|
||||
impl InstrumentedPool {
|
||||
pub fn new(inner: deadpool_redis::Pool) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
pub async fn get(&self) -> Result<deadpool_redis::Connection, PoolError> {
|
||||
self.inner
|
||||
.get()
|
||||
.instrument(info_span!("get redis connection"))
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redis_pipe() -> InstrumentedPipeline {
|
||||
InstrumentedPipeline {
|
||||
inner: redis::pipe(),
|
||||
@@ -55,7 +36,7 @@ impl InstrumentedPipeline {
|
||||
) -> RedisResult<T> {
|
||||
self.inner
|
||||
.query_async(con)
|
||||
.instrument(info_span!("execute redis pipeline"))
|
||||
.instrument(info_span!("pipeline.query_async"))
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -88,7 +69,7 @@ impl InstrumentedCmd {
|
||||
con: &mut impl redis::aio::ConnectionLike,
|
||||
) -> RedisResult<T> {
|
||||
let span = info_span!(
|
||||
"query_async",
|
||||
"cmd.query_async",
|
||||
// <https://opentelemetry.io/docs/specs/semconv/db/redis/>
|
||||
db.system.name = "redis",
|
||||
db.operation.name = self.name,
|
||||
|
||||
+12
-20
@@ -18,7 +18,6 @@ use crate::background_task::update_versions;
|
||||
use crate::database::{PgPool, ReadOnlyPgPool};
|
||||
use crate::queue::billing::{index_billing, index_subscriptions};
|
||||
use crate::queue::moderation::AutomatedModerationQueue;
|
||||
use crate::search::MeilisearchReadClient;
|
||||
use crate::util::anrok;
|
||||
use crate::util::archon::ArchonClient;
|
||||
use crate::util::env::{parse_strings_from_var, parse_var};
|
||||
@@ -68,7 +67,6 @@ pub struct LabrinthConfig {
|
||||
pub email_queue: web::Data<EmailQueue>,
|
||||
pub archon_client: web::Data<ArchonClient>,
|
||||
pub gotenberg_client: GotenbergClient,
|
||||
pub search_read_client: web::Data<MeilisearchReadClient>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -275,11 +273,6 @@ pub fn app_setup(
|
||||
file_host,
|
||||
scheduler: Arc::new(scheduler),
|
||||
ip_salt,
|
||||
search_read_client: web::Data::new(
|
||||
search_config.make_loadbalanced_read_client().expect(
|
||||
"Failed to make Meilisearch client for read operations",
|
||||
),
|
||||
),
|
||||
search_config,
|
||||
session_queue,
|
||||
payouts_queue: web::Data::new(PayoutsQueue::new()),
|
||||
@@ -331,18 +324,7 @@ pub fn app_config(
|
||||
.app_data(labrinth_config.archon_client.clone())
|
||||
.app_data(web::Data::new(labrinth_config.stripe_client.clone()))
|
||||
.app_data(web::Data::new(labrinth_config.anrok_client.clone()))
|
||||
.app_data(labrinth_config.search_read_client.clone())
|
||||
.app_data(labrinth_config.rate_limiter.clone())
|
||||
.configure({
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
|cfg| routes::debug::config(cfg)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
|_cfg| ()
|
||||
}
|
||||
})
|
||||
.configure(routes::v2::config)
|
||||
.configure(routes::v3::config)
|
||||
.configure(routes::internal::config)
|
||||
@@ -354,8 +336,18 @@ pub fn utoipa_app_config(
|
||||
cfg: &mut utoipa_actix_web::service_config::ServiceConfig,
|
||||
_labrinth_config: LabrinthConfig,
|
||||
) {
|
||||
cfg.configure(routes::v3::utoipa_config)
|
||||
.configure(routes::internal::utoipa_config);
|
||||
cfg.configure({
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
|cfg| routes::debug::config(cfg)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
|_cfg| ()
|
||||
}
|
||||
})
|
||||
.configure(routes::v3::utoipa_config)
|
||||
.configure(routes::internal::utoipa_config);
|
||||
}
|
||||
|
||||
// This is so that env vars not used immediately don't panic at runtime
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,9 +14,7 @@ use crate::queue::moderation::AutomatedModerationQueue;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::v3::projects::ProjectIds;
|
||||
use crate::routes::{ApiError, v2_reroute, v3};
|
||||
use crate::search::{
|
||||
MeilisearchReadClient, SearchConfig, SearchError, search_for_project,
|
||||
};
|
||||
use crate::search::{SearchConfig, SearchError, search_for_project};
|
||||
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -56,7 +54,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
pub async fn project_search(
|
||||
web::Query(info): web::Query<SearchRequest>,
|
||||
config: web::Data<SearchConfig>,
|
||||
read_client: web::Data<MeilisearchReadClient>,
|
||||
) -> Result<HttpResponse, SearchError> {
|
||||
// Search now uses loader_fields instead of explicit 'client_side' and 'server_side' fields
|
||||
// While the backend for this has changed, it doesnt affect much
|
||||
@@ -102,7 +99,7 @@ pub async fn project_search(
|
||||
..info
|
||||
};
|
||||
|
||||
let results = search_for_project(&info, &config, &read_client).await?;
|
||||
let results = search_for_project(&info, &config).await?;
|
||||
|
||||
let results = LegacySearchResults::from(results);
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -28,9 +28,7 @@ use crate::queue::moderation::AutomatedModerationQueue;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::search::indexing::remove_documents;
|
||||
use crate::search::{
|
||||
MeilisearchReadClient, SearchConfig, SearchError, search_for_project,
|
||||
};
|
||||
use crate::search::{SearchConfig, SearchError, search_for_project};
|
||||
use crate::util::img;
|
||||
use crate::util::img::{delete_old_images, upload_image_optimized};
|
||||
use crate::util::routes::read_limited_from_payload;
|
||||
@@ -1039,9 +1037,8 @@ pub async fn edit_project_categories(
|
||||
pub async fn project_search(
|
||||
web::Query(info): web::Query<SearchRequest>,
|
||||
config: web::Data<SearchConfig>,
|
||||
read_client: web::Data<MeilisearchReadClient>,
|
||||
) -> Result<HttpResponse, SearchError> {
|
||||
let results = search_for_project(&info, &config, &read_client).await?;
|
||||
let results = search_for_project(&info, &config).await?;
|
||||
|
||||
// TODO: add this back
|
||||
// let results = ReturnSearchResults {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -39,14 +39,22 @@ pub enum IndexingError {
|
||||
//
|
||||
// Set this to 50k for better observability
|
||||
const MEILISEARCH_CHUNK_SIZE: usize = 50000; // 10_000_000
|
||||
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
|
||||
fn search_operation_timeout() -> std::time::Duration {
|
||||
let default_ms = 5 * 60 * 1000; // 5 minutes
|
||||
let ms = dotenvy::var("SEARCH_OPERATION_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(default_ms);
|
||||
std::time::Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
pub async fn remove_documents(
|
||||
ids: &[crate::models::ids::VersionId],
|
||||
config: &SearchConfig,
|
||||
) -> Result<(), IndexingError> {
|
||||
let mut indexes = get_indexes_for_indexing(config, false).await?;
|
||||
let indexes_next = get_indexes_for_indexing(config, true).await?;
|
||||
let mut indexes = get_indexes_for_indexing(config, false, false).await?;
|
||||
let indexes_next = get_indexes_for_indexing(config, true, false).await?;
|
||||
|
||||
for list in &mut indexes {
|
||||
for alt_list in &indexes_next {
|
||||
@@ -94,11 +102,11 @@ pub async fn index_projects(
|
||||
|
||||
info!("Ensuring current indexes exists");
|
||||
// First, ensure current index exists (so no error happens- current index should be worst-case empty, not missing)
|
||||
get_indexes_for_indexing(config, false).await?;
|
||||
get_indexes_for_indexing(config, false, false).await?;
|
||||
|
||||
info!("Deleting surplus indexes");
|
||||
// Then, delete the next index if it still exists
|
||||
let indices = get_indexes_for_indexing(config, true).await?;
|
||||
let indices = get_indexes_for_indexing(config, true, false).await?;
|
||||
for client_indices in indices {
|
||||
for index in client_indices {
|
||||
index.delete().await?;
|
||||
@@ -107,7 +115,7 @@ pub async fn index_projects(
|
||||
|
||||
info!("Recreating next index");
|
||||
// Recreate the next index for indexing
|
||||
let indices = get_indexes_for_indexing(config, true).await?;
|
||||
let indices = get_indexes_for_indexing(config, true, true).await?;
|
||||
|
||||
let all_loader_fields =
|
||||
crate::database::models::loader_fields::LoaderField::get_fields_all(
|
||||
@@ -208,6 +216,7 @@ pub async fn swap_index(
|
||||
pub async fn get_indexes_for_indexing(
|
||||
config: &SearchConfig,
|
||||
next: bool, // Get the 'next' one
|
||||
update_settings: bool,
|
||||
) -> Result<Vec<Vec<Index>>, IndexingError> {
|
||||
let client = config.make_batch_client()?;
|
||||
let project_name = config.get_index_name("projects", next);
|
||||
@@ -230,6 +239,7 @@ pub async fn get_indexes_for_indexing(
|
||||
"exactness",
|
||||
"sort",
|
||||
]),
|
||||
update_settings,
|
||||
)
|
||||
.await?;
|
||||
let projects_filtered_index = create_or_update_index(
|
||||
@@ -243,6 +253,7 @@ pub async fn get_indexes_for_indexing(
|
||||
"attribute",
|
||||
"exactness",
|
||||
]),
|
||||
update_settings,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -258,6 +269,7 @@ async fn create_or_update_index(
|
||||
client: &Client,
|
||||
name: &str,
|
||||
custom_rules: Option<&'static [&'static str]>,
|
||||
update_settings: bool,
|
||||
) -> Result<Index, meilisearch_sdk::errors::Error> {
|
||||
info!("Updating/creating index");
|
||||
|
||||
@@ -271,16 +283,26 @@ async fn create_or_update_index(
|
||||
settings = settings.with_ranking_rules(custom_rules);
|
||||
}
|
||||
|
||||
info!("Performing index settings set.");
|
||||
index
|
||||
.set_settings(&settings)
|
||||
.await
|
||||
.inspect_err(|e| error!("Error setting index settings: {e:?}"))?
|
||||
.wait_for_completion(client, None, Some(TIMEOUT))
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
error!("Error setting index settings while waiting: {e:?}")
|
||||
})?;
|
||||
if update_settings {
|
||||
info!("Updating index settings");
|
||||
index
|
||||
.set_settings(&settings)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
error!("Error setting index settings: {e:?}")
|
||||
})?
|
||||
.wait_for_completion(
|
||||
client,
|
||||
None,
|
||||
Some(search_operation_timeout()),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
error!(
|
||||
"Error setting index settings while waiting: {e:?}"
|
||||
)
|
||||
})?;
|
||||
}
|
||||
info!("Done performing index settings set.");
|
||||
|
||||
Ok(index)
|
||||
@@ -291,7 +313,11 @@ async fn create_or_update_index(
|
||||
// Only create index and set settings if the index doesn't already exist
|
||||
let task = client.create_index(name, Some("version_id")).await?;
|
||||
let task = task
|
||||
.wait_for_completion(client, None, Some(TIMEOUT))
|
||||
.wait_for_completion(
|
||||
client,
|
||||
None,
|
||||
Some(search_operation_timeout()),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
error!("Error creating index while waiting: {e:?}")
|
||||
@@ -306,15 +332,25 @@ async fn create_or_update_index(
|
||||
settings = settings.with_ranking_rules(custom_rules);
|
||||
}
|
||||
|
||||
index
|
||||
.set_settings(&settings)
|
||||
.await
|
||||
.inspect_err(|e| error!("Error setting index settings: {e:?}"))?
|
||||
.wait_for_completion(client, None, Some(TIMEOUT))
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
error!("Error setting index settings while waiting: {e:?}")
|
||||
})?;
|
||||
if update_settings {
|
||||
index
|
||||
.set_settings(&settings)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
error!("Error setting index settings: {e:?}")
|
||||
})?
|
||||
.wait_for_completion(
|
||||
client,
|
||||
None,
|
||||
Some(search_operation_timeout()),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
error!(
|
||||
"Error setting index settings while waiting: {e:?}"
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(index)
|
||||
}
|
||||
@@ -436,10 +472,10 @@ async fn update_and_add_to_index(
|
||||
|
||||
// // Allow a long timeout for adding new attributes- it only needs to happen the once
|
||||
// filterable_task
|
||||
// .wait_for_completion(client, None, Some(TIMEOUT * 100))
|
||||
// .wait_for_completion(client, None, Some(search_operation_timeout() * 100))
|
||||
// .await?;
|
||||
// displayable_task
|
||||
// .wait_for_completion(client, None, Some(TIMEOUT * 100))
|
||||
// .wait_for_completion(client, None, Some(search_operation_timeout() * 100))
|
||||
// .await?;
|
||||
// }
|
||||
|
||||
|
||||
@@ -283,7 +283,6 @@ pub fn get_sort_index(
|
||||
pub async fn search_for_project(
|
||||
info: &SearchRequest,
|
||||
config: &SearchConfig,
|
||||
client: &MeilisearchReadClient,
|
||||
) -> Result<SearchResults, SearchError> {
|
||||
let offset: usize = info.offset.as_deref().unwrap_or("0").parse()?;
|
||||
let index = info.index.as_deref().unwrap_or("relevance");
|
||||
@@ -295,6 +294,7 @@ pub async fn search_for_project(
|
||||
.min(100);
|
||||
|
||||
let sort = get_sort_index(config, index)?;
|
||||
let client = config.make_loadbalanced_read_client()?;
|
||||
let meilisearch_index = client.get_index(sort.0).await?;
|
||||
|
||||
let mut filter_string = String::new();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -23,10 +23,12 @@ pub async fn set(settings: Settings) -> crate::Result<()> {
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn cancel_directory_change() -> crate::Result<()> {
|
||||
pub async fn cancel_directory_change(
|
||||
app_identifier: &str,
|
||||
) -> crate::Result<()> {
|
||||
// This is called to handle state initialization errors due to folder migrations
|
||||
// failing, so fetching a DB connection pool from `State::get` is not reliable here
|
||||
let pool = crate::state::db::connect().await?;
|
||||
let pool = crate::state::db::connect(app_identifier).await?;
|
||||
let mut settings = Settings::get(&pool).await?;
|
||||
|
||||
if let Some(prev_custom_dir) = settings.prev_custom_dir {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
// Handling for the live development logging
|
||||
// This will log to the console, and will not log to a file
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn start_logger() -> Option<()> {
|
||||
pub fn start_logger(_app_identifier: &str) -> Option<()> {
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
@@ -36,7 +36,7 @@ pub fn start_logger() -> Option<()> {
|
||||
// Handling for the live production logging
|
||||
// This will log to a file in the logs directory, and will not show any logs in the console
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub fn start_logger() -> Option<()> {
|
||||
pub fn start_logger(app_identifier: &str) -> Option<()> {
|
||||
use crate::prelude::DirectoryInfo;
|
||||
use chrono::Local;
|
||||
use std::fs::OpenOptions;
|
||||
@@ -44,7 +44,9 @@ pub fn start_logger() -> Option<()> {
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
// Initialize and get logs directory path
|
||||
let logs_dir = if let Some(d) = DirectoryInfo::launcher_logs_dir() {
|
||||
let logs_dir = if let Some(d) =
|
||||
DirectoryInfo::launcher_logs_dir_path(app_identifier)
|
||||
{
|
||||
d
|
||||
} else {
|
||||
eprintln!("Could not start logger");
|
||||
|
||||
@@ -6,12 +6,13 @@ use sqlx::{Pool, Sqlite};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) async fn connect() -> crate::Result<Pool<Sqlite>> {
|
||||
let settings_dir = DirectoryInfo::get_initial_settings_dir().ok_or(
|
||||
crate::ErrorKind::FSError(
|
||||
pub(crate) async fn connect(
|
||||
app_identifier: &str,
|
||||
) -> crate::Result<Pool<Sqlite>> {
|
||||
let settings_dir = DirectoryInfo::initial_settings_dir_path(app_identifier)
|
||||
.ok_or(crate::ErrorKind::FSError(
|
||||
"Could not find valid config dir".to_string(),
|
||||
),
|
||||
)?;
|
||||
))?;
|
||||
|
||||
if !settings_dir.exists() {
|
||||
crate::util::io::create_dir_all(&settings_dir).await?;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Theseus directory information
|
||||
use crate::LoadingBarType;
|
||||
use crate::event::emit::{emit_loading, init_loading};
|
||||
use crate::state::LAUNCHER_STATE;
|
||||
use crate::state::{JavaVersion, Profile, Settings};
|
||||
use crate::util::fetch::IoSemaphore;
|
||||
use dashmap::DashSet;
|
||||
@@ -17,24 +18,35 @@ pub const METADATA_FOLDER_NAME: &str = "meta";
|
||||
pub struct DirectoryInfo {
|
||||
pub settings_dir: PathBuf, // Base settings directory- app database
|
||||
pub config_dir: PathBuf, // Base config directory- instances, minecraft downloads, etc. Changeable as a setting.
|
||||
pub app_identifier: String,
|
||||
}
|
||||
|
||||
impl DirectoryInfo {
|
||||
pub fn global_handle_if_ready() -> Option<&'static Self> {
|
||||
LAUNCHER_STATE.get().map(|x| &x.directories)
|
||||
}
|
||||
|
||||
pub fn get_initial_settings_dir(&self) -> Option<PathBuf> {
|
||||
Self::initial_settings_dir_path(&self.app_identifier)
|
||||
}
|
||||
|
||||
// Get the settings directory
|
||||
// init() is not needed for this function
|
||||
pub fn get_initial_settings_dir() -> Option<PathBuf> {
|
||||
pub fn initial_settings_dir_path(app_identifier: &str) -> Option<PathBuf> {
|
||||
Self::env_path("THESEUS_CONFIG_DIR")
|
||||
.or_else(|| Some(dirs::data_dir()?.join("ModrinthApp")))
|
||||
.or_else(|| Some(dirs::data_dir()?.join(app_identifier)))
|
||||
}
|
||||
|
||||
/// Get all paths needed for Theseus to operate properly
|
||||
#[tracing::instrument]
|
||||
pub async fn init(config_dir: Option<String>) -> crate::Result<Self> {
|
||||
let settings_dir = Self::get_initial_settings_dir().ok_or(
|
||||
crate::ErrorKind::FSError(
|
||||
pub async fn init(
|
||||
config_dir: Option<String>,
|
||||
app_identifier: &str,
|
||||
) -> crate::Result<Self> {
|
||||
let settings_dir = Self::initial_settings_dir_path(app_identifier)
|
||||
.ok_or(crate::ErrorKind::FSError(
|
||||
"Could not find valid settings dir".to_string(),
|
||||
),
|
||||
)?;
|
||||
))?;
|
||||
|
||||
fs::create_dir_all(&settings_dir).await.map_err(|err| {
|
||||
crate::ErrorKind::FSError(format!(
|
||||
@@ -48,6 +60,7 @@ impl DirectoryInfo {
|
||||
Ok(Self {
|
||||
settings_dir,
|
||||
config_dir,
|
||||
app_identifier: app_identifier.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,8 +167,14 @@ impl DirectoryInfo {
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn launcher_logs_dir() -> Option<PathBuf> {
|
||||
Self::get_initial_settings_dir()
|
||||
pub fn launcher_logs_dir(&self) -> Option<PathBuf> {
|
||||
self.get_initial_settings_dir()
|
||||
.map(|d| d.join(LAUNCHER_LOGS_FOLDER_NAME))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn launcher_logs_dir_path(app_identifier: &str) -> Option<PathBuf> {
|
||||
Self::initial_settings_dir_path(app_identifier)
|
||||
.map(|d| d.join(LAUNCHER_LOGS_FOLDER_NAME))
|
||||
}
|
||||
|
||||
@@ -176,15 +195,15 @@ impl DirectoryInfo {
|
||||
settings: &mut Settings,
|
||||
exec: E,
|
||||
io_semaphore: &IoSemaphore,
|
||||
app_identifier: &str,
|
||||
) -> crate::Result<()>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Sqlite> + Copy,
|
||||
{
|
||||
let app_dir = DirectoryInfo::get_initial_settings_dir().ok_or(
|
||||
crate::ErrorKind::FSError(
|
||||
let app_dir = DirectoryInfo::initial_settings_dir_path(app_identifier)
|
||||
.ok_or(crate::ErrorKind::FSError(
|
||||
"Could not find valid config dir".to_string(),
|
||||
),
|
||||
)?;
|
||||
))?;
|
||||
|
||||
if let Some(ref prev_custom_dir) = settings.prev_custom_dir {
|
||||
let prev_dir = PathBuf::from(prev_custom_dir);
|
||||
|
||||
@@ -71,6 +71,9 @@ pub struct State {
|
||||
/// Process manager
|
||||
pub process_manager: ProcessManager,
|
||||
|
||||
/// App identifier string (like com.modrinth.ModrinthApp)
|
||||
pub app_identifier: String,
|
||||
|
||||
/// Friends socket
|
||||
pub friends_socket: FriendsSocket,
|
||||
|
||||
@@ -80,9 +83,9 @@ pub struct State {
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub async fn init() -> crate::Result<()> {
|
||||
pub async fn init(app_identifier: String) -> crate::Result<()> {
|
||||
let state = LAUNCHER_STATE
|
||||
.get_or_try_init(Self::initialize_state)
|
||||
.get_or_try_init(move || Self::initialize_state(app_identifier))
|
||||
.await?;
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
@@ -132,9 +135,11 @@ impl State {
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
async fn initialize_state() -> crate::Result<Arc<Self>> {
|
||||
async fn initialize_state(
|
||||
app_identifier: String,
|
||||
) -> crate::Result<Arc<Self>> {
|
||||
tracing::info!("Connecting to app database");
|
||||
let pool = db::connect().await?;
|
||||
let pool = db::connect(&app_identifier).await?;
|
||||
|
||||
legacy_converter::migrate_legacy_data(&pool).await?;
|
||||
|
||||
@@ -153,9 +158,12 @@ impl State {
|
||||
&mut settings,
|
||||
&pool,
|
||||
&io_semaphore,
|
||||
&app_identifier,
|
||||
)
|
||||
.await?;
|
||||
let directories = DirectoryInfo::init(settings.custom_dir).await?;
|
||||
|
||||
let directories =
|
||||
DirectoryInfo::init(settings.custom_dir, &app_identifier).await?;
|
||||
|
||||
let discord_rpc = DiscordGuard::init()?;
|
||||
|
||||
@@ -177,6 +185,7 @@ impl State {
|
||||
friends_socket,
|
||||
pool,
|
||||
file_watcher,
|
||||
app_identifier,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,11 +28,13 @@
|
||||
'--_width': width,
|
||||
}"
|
||||
>
|
||||
<div class="modal-body flex flex-col bg-bg-raised rounded-2xl">
|
||||
<div
|
||||
class="modal-body flex flex-col bg-bg-raised rounded-2xl border border-solid border-surface-5"
|
||||
>
|
||||
<div
|
||||
v-if="!hideHeader"
|
||||
data-tauri-drag-region
|
||||
class="grid grid-cols-[auto_min-content] items-center gap-4 p-6 border-solid border-0 border-b-[1px] border-divider max-w-full"
|
||||
class="grid grid-cols-[auto_min-content] items-center gap-4 p-6 border-solid border-0 border-b-[1px] border-surface-5 max-w-full"
|
||||
>
|
||||
<div class="flex text-wrap break-words items-center gap-3 min-w-0">
|
||||
<slot name="title">
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<XCircleIcon v-else-if="item.type === 'error'" class="h-6 w-6" />
|
||||
<InfoIcon v-else class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="m-0 text-wrap font-bold text-contrast" v-html="item.title"></div>
|
||||
<div class="m-0 text-wrap font-bold text-contrast">{{ item.title }}</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div v-if="item.count && item.count > 1" class="text-xs font-bold text-contrast">
|
||||
x{{ item.count }}
|
||||
@@ -66,13 +66,12 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div></div>
|
||||
<div class="col-span-2 text-sm text-primary" v-html="item.text"></div>
|
||||
<div class="col-span-2 text-sm text-primary">{{ item.text }}</div>
|
||||
<template v-if="item.errorCode">
|
||||
<div></div>
|
||||
<div
|
||||
class="m-0 text-wrap text-xs font-medium text-secondary"
|
||||
v-html="item.errorCode"
|
||||
></div>
|
||||
<div class="m-0 text-wrap text-xs font-medium text-secondary">
|
||||
{{ item.errorCode }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
v-if="isOpen"
|
||||
ref="menuRef"
|
||||
data-pyro-telepopover-root
|
||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-divider bg-bg-raised p-2 shadow-lg"
|
||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -677,6 +689,9 @@
|
||||
"project-type.plugin.lowercase": {
|
||||
"defaultMessage": "{count, plural, one {plugin} other {plugins}}"
|
||||
},
|
||||
"project-type.project.capital": {
|
||||
"defaultMessage": "{count, plural, one {Project} other {Projects}}"
|
||||
},
|
||||
"project-type.project.category": {
|
||||
"defaultMessage": "Projects"
|
||||
},
|
||||
|
||||
@@ -557,7 +557,7 @@ export const commonProjectTypeTitleMessages = defineMessages({
|
||||
defaultMessage: '{count, plural, one {Server} other {Servers}}',
|
||||
},
|
||||
project: {
|
||||
id: 'project-type.project.lowercase',
|
||||
id: 'project-type.project.capital',
|
||||
defaultMessage: '{count, plural, one {Project} other {Projects}}',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
@@ -423,5 +446,10 @@ export function formatCategory(formatter: VIntlFormatters['formatMessage'], cate
|
||||
}
|
||||
|
||||
export function formatLoader(formatter: VIntlFormatters['formatMessage'], category: string) {
|
||||
return formatTag(formatter, category, 'category')
|
||||
return formatTag(formatter, category, 'loader')
|
||||
}
|
||||
|
||||
export function formatCategoryHeader(formatter: VIntlFormatters['formatMessage'], header: string) {
|
||||
const message = getCategoryHeaderMessage(header)
|
||||
return message ? formatter(message) : capitalizeString(header)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,38 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2026-02-04T16:00:00-08:00`,
|
||||
product: 'app',
|
||||
version: '0.10.28',
|
||||
body: `## Improvements
|
||||
- Added app update notification for Linux users.
|
||||
- Fixed security policy issue updating capes.
|
||||
- Adjusted pop-up design to include a border.
|
||||
- Updated translations.`,
|
||||
},
|
||||
{
|
||||
date: `2026-02-04T15:00:00-08:00`,
|
||||
product: 'web',
|
||||
body: `## Improvements
|
||||
- Adjusted pop-up design to include a border.
|
||||
- Changed Russian and Italian to always use numeric relative dates.
|
||||
- Made category headers translatable.
|
||||
- Fixed issue withdrawing gift cards in other currencies.`,
|
||||
},
|
||||
{
|
||||
date: `2026-02-03T09:15:00-08:00`,
|
||||
product: 'web',
|
||||
body: `## Improvements
|
||||
- Fixed some .jar files being detected as Resource Packs when uploading new versions.`,
|
||||
},
|
||||
{
|
||||
date: `2026-02-02T13:20:00-08:00`,
|
||||
product: 'web',
|
||||
body: `## Improvements
|
||||
- Made some clarity improvements to the Personal Access Token screen.
|
||||
- Updated translations and made mod loader in a number of places translatable.`,
|
||||
},
|
||||
{
|
||||
date: `2026-01-30T13:10:00-08:00`,
|
||||
product: 'web',
|
||||
|
||||
@@ -162,66 +162,6 @@ export const formatProjectType = (name, short = false) => {
|
||||
return capitalizeString(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use tag-messages.ts' `formatTag(tag)` instead.
|
||||
*/
|
||||
export const formatCategory = (name) => {
|
||||
if (name === 'modloader') {
|
||||
return "Risugami's ModLoader"
|
||||
} else if (name === 'bungeecord') {
|
||||
return 'BungeeCord'
|
||||
} else if (name === 'liteloader') {
|
||||
return 'LiteLoader'
|
||||
} else if (name === 'neoforge') {
|
||||
return 'NeoForge'
|
||||
} else if (name === 'game-mechanics') {
|
||||
return 'Game Mechanics'
|
||||
} else if (name === 'worldgen') {
|
||||
return 'World Generation'
|
||||
} else if (name === 'core-shaders') {
|
||||
return 'Core Shaders'
|
||||
} else if (name === 'gui') {
|
||||
return 'GUI'
|
||||
} else if (name === '8x-') {
|
||||
return '8x or lower'
|
||||
} else if (name === '512x+') {
|
||||
return '512x or higher'
|
||||
} else if (name === 'kitchen-sink') {
|
||||
return 'Kitchen Sink'
|
||||
} else if (name === 'path-tracing') {
|
||||
return 'Path Tracing'
|
||||
} else if (name === 'pbr') {
|
||||
return 'PBR'
|
||||
} else if (name === 'datapack') {
|
||||
return 'Data Pack'
|
||||
} else if (name === 'colored-lighting') {
|
||||
return 'Colored Lighting'
|
||||
} else if (name === 'optifine') {
|
||||
return 'OptiFine'
|
||||
} else if (name === 'bta-babric') {
|
||||
return 'BTA (Babric)'
|
||||
} else if (name === 'legacy-fabric') {
|
||||
return 'Legacy Fabric'
|
||||
} else if (name === 'java-agent') {
|
||||
return 'Java Agent'
|
||||
} else if (name === 'nilloader') {
|
||||
return 'NilLoader'
|
||||
} else if (name === 'mrpack') {
|
||||
return 'Modpack'
|
||||
} else if (name === 'minecraft') {
|
||||
return 'Resource Pack'
|
||||
} else if (name === 'vanilla') {
|
||||
return 'Vanilla Shader'
|
||||
} else if (name === 'geyser') {
|
||||
return 'Geyser Extension'
|
||||
}
|
||||
return capitalizeString(name)
|
||||
}
|
||||
|
||||
export const formatCategoryHeader = (name) => {
|
||||
return capitalizeString(name)
|
||||
}
|
||||
|
||||
export const formatProjectStatus = (name) => {
|
||||
if (name === 'approved') {
|
||||
return 'Public'
|
||||
|
||||
Reference in New Issue
Block a user