mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d2f68532d | ||
|
|
27ad453f2d | ||
|
|
e9dfe1b7f0 | ||
|
|
dfb6814095 | ||
|
|
a80cc7e47b | ||
|
|
fc90e1098e | ||
|
|
747fe04888 | ||
|
|
ab7f649177 | ||
|
|
b39544b50e | ||
|
|
d65e465543 | ||
|
|
ba1d374be6 | ||
|
|
620894aecb | ||
|
|
85ae1f2074 | ||
|
|
3f8fd9cb56 | ||
|
|
a2eed001b2 | ||
|
|
6afda48e70 | ||
|
|
e8be67d41f | ||
|
|
548357c92c | ||
|
|
453369ca07 | ||
|
|
faf593b2af | ||
|
|
e3d6a498d0 | ||
|
|
42cdcc7df9 | ||
|
|
e043a232bc | ||
|
|
c44ead2dbe | ||
|
|
11ac27f71f | ||
|
|
6862cf5ab2 | ||
|
|
16e1bf4611 | ||
|
|
77e4c41480 |
@@ -26,11 +26,21 @@ jobs:
|
||||
|
||||
const sections = ['### Added', '', '### Changed', '', '### Deprecated', '', '### Removed', '', '### Fixed', '', '### Security'].join('\n');
|
||||
const productBlock = (name) => `<details>\n<summary>${name}</summary>\n\n${sections}\n\n</details>`;
|
||||
const bakeStatus = [
|
||||
'<!-- changelog:bake-status -->',
|
||||
'> [!NOTE]',
|
||||
'> Changelog bake status:',
|
||||
'> - [ ] App <!-- changelog:product:app -->',
|
||||
'> - [ ] Website <!-- changelog:product:web -->',
|
||||
'> - [ ] Hosting <!-- changelog:product:hosting -->',
|
||||
].join('\n');
|
||||
|
||||
const template = [
|
||||
marker,
|
||||
'## Pull request changelog',
|
||||
'',
|
||||
bakeStatus,
|
||||
'',
|
||||
'<!-- Fill in the changelog under each product area this PR affects.',
|
||||
' Empty sections are ignored. Leave a product collapsed/empty',
|
||||
' if it doesn\'t apply. -->',
|
||||
|
||||
Generated
+25
-14
@@ -27,7 +27,7 @@ checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d"
|
||||
dependencies = [
|
||||
"actix-utils",
|
||||
"actix-web",
|
||||
"derive_more 2.0.1",
|
||||
"derive_more 2.1.1",
|
||||
"futures-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
@@ -46,7 +46,7 @@ dependencies = [
|
||||
"actix-web",
|
||||
"bitflags 2.9.4",
|
||||
"bytes",
|
||||
"derive_more 2.0.1",
|
||||
"derive_more 2.1.1",
|
||||
"futures-core",
|
||||
"http-range",
|
||||
"log",
|
||||
@@ -72,7 +72,7 @@ dependencies = [
|
||||
"brotli",
|
||||
"bytes",
|
||||
"bytestring",
|
||||
"derive_more 2.0.1",
|
||||
"derive_more 2.1.1",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"foldhash",
|
||||
@@ -226,7 +226,7 @@ dependencies = [
|
||||
"bytestring",
|
||||
"cfg-if",
|
||||
"cookie 0.16.2",
|
||||
"derive_more 2.0.1",
|
||||
"derive_more 2.1.1",
|
||||
"encoding_rs",
|
||||
"foldhash",
|
||||
"futures-core",
|
||||
@@ -1902,6 +1902,15 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.16.2"
|
||||
@@ -2450,21 +2459,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.0.1"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.0.1"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case 0.10.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.106",
|
||||
"unicode-xid",
|
||||
]
|
||||
@@ -4914,7 +4925,7 @@ dependencies = [
|
||||
"const_format",
|
||||
"dashmap",
|
||||
"deadpool-redis",
|
||||
"derive_more 2.0.1",
|
||||
"derive_more 2.1.1",
|
||||
"dotenv-build",
|
||||
"dotenvy",
|
||||
"either",
|
||||
@@ -5516,7 +5527,7 @@ name = "modrinth-util"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"actix-web",
|
||||
"derive_more 2.0.1",
|
||||
"derive_more 2.1.1",
|
||||
"dotenvy",
|
||||
"eyre",
|
||||
"modrinth-log",
|
||||
@@ -5582,7 +5593,7 @@ dependencies = [
|
||||
"arc-swap",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"derive_more 2.0.1",
|
||||
"derive_more 2.1.1",
|
||||
"reqwest 0.12.24",
|
||||
"rust_decimal",
|
||||
"rust_iso3166",
|
||||
@@ -6593,7 +6604,7 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
name = "path-util"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"derive_more 2.0.1",
|
||||
"derive_more 2.1.1",
|
||||
"itertools 0.14.0",
|
||||
"serde",
|
||||
"typed-path",
|
||||
@@ -9275,7 +9286,7 @@ dependencies = [
|
||||
name = "sqlx-tracing"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"derive_more 2.0.1",
|
||||
"derive_more 2.1.1",
|
||||
"futures",
|
||||
"opentelemetry",
|
||||
"opentelemetry-testing",
|
||||
@@ -10148,7 +10159,7 @@ dependencies = [
|
||||
"daedalus",
|
||||
"dashmap",
|
||||
"data-url",
|
||||
"derive_more 2.0.1",
|
||||
"derive_more 2.1.1",
|
||||
"dirs",
|
||||
"discord-rich-presence",
|
||||
"dotenvy",
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ darling = { version = "0.23" }
|
||||
dashmap = "6.1.0"
|
||||
data-url = "0.3.2"
|
||||
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "db5fb00b036ecc8fe5f18853c559b745ffe47bde", version = "0.22.1" }
|
||||
derive_more = "2.0.1"
|
||||
derive_more = "2.1.1"
|
||||
directories = "6.0.0"
|
||||
dirs = "6.0.0"
|
||||
discord-rich-presence = "1.0.0"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"test": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@intercom/messenger-js-sdk": "^0.0.14",
|
||||
"@modrinth/api-client": "workspace:^",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { Intercom, shutdown as shutdownIntercom } from '@intercom/messenger-js-sdk'
|
||||
import {
|
||||
AuthFeature,
|
||||
NodeAuthFeature,
|
||||
@@ -67,11 +68,13 @@ import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
||||
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import AppActionBar from '@/components/ui/AppActionBar.vue'
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||
import ErrorModal from '@/components/ui/ErrorModal.vue'
|
||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
|
||||
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
|
||||
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
|
||||
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
|
||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
||||
@@ -81,7 +84,6 @@ import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
import WindowControls from '@/components/ui/WindowControls.vue'
|
||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||
@@ -171,15 +173,17 @@ provideModalBehavior({
|
||||
|
||||
const {
|
||||
installationModal,
|
||||
unknownPackWarningModal,
|
||||
fetchExistingInstanceNames,
|
||||
handleCreate,
|
||||
handleBrowseModpacks,
|
||||
searchModpacks,
|
||||
getProjectVersions,
|
||||
getLoaderManifest,
|
||||
setModpackAlreadyInstalledModal,
|
||||
handleModpackDuplicateCreateAnyway,
|
||||
handleModpackDuplicateGoToInstance,
|
||||
} = setupProviders(notificationManager)
|
||||
} = setupProviders(notificationManager, popupNotificationManager)
|
||||
|
||||
const news = ref([])
|
||||
const availableSurvey = ref(false)
|
||||
@@ -237,6 +241,7 @@ onMounted(async () => {
|
||||
onUnmounted(async () => {
|
||||
document.querySelector('body').removeEventListener('click', handleClick)
|
||||
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
|
||||
shutdownHostingIntercom()
|
||||
|
||||
await unlistenUpdateDownload?.()
|
||||
})
|
||||
@@ -651,6 +656,102 @@ const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value
|
||||
const showAd = computed(
|
||||
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
|
||||
)
|
||||
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
|
||||
const INTERCOM_DEFAULT_PADDING = 20
|
||||
const INTERCOM_APP_SIDEBAR_WIDTH = 300
|
||||
|
||||
let intercomBooting = false
|
||||
let intercomBooted = false
|
||||
|
||||
async function fetchIntercomToken() {
|
||||
const creds = await getCreds()
|
||||
if (!creds?.session) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (route.path.startsWith('/hosting/manage/') && typeof route.params.id === 'string') {
|
||||
params.set('server_id', route.params.id)
|
||||
}
|
||||
const query = params.size > 0 ? `?${params.toString()}` : ''
|
||||
|
||||
const response = await tauriFetch(`${config.siteUrl}/api/intercom/messenger-jwt${query}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${creds.session}`,
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Intercom token: ${response.status}`)
|
||||
}
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async function bootIntercom() {
|
||||
if (
|
||||
intercomBooting ||
|
||||
intercomBooted ||
|
||||
!hostingRouteActive.value ||
|
||||
!credentials.value?.session
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
intercomBooting = true
|
||||
console.debug('[APP][INTERCOM] initializing secure support chat')
|
||||
try {
|
||||
const { token } = await fetchIntercomToken()
|
||||
Intercom({
|
||||
app_id: 'ykeritl9',
|
||||
intercom_user_jwt: token,
|
||||
session_duration: 1000 * 60 * 60 * 24,
|
||||
alignment: 'right',
|
||||
horizontal_padding: sidebarVisible.value
|
||||
? INTERCOM_APP_SIDEBAR_WIDTH + INTERCOM_DEFAULT_PADDING
|
||||
: INTERCOM_DEFAULT_PADDING,
|
||||
vertical_padding: INTERCOM_DEFAULT_PADDING,
|
||||
})
|
||||
intercomBooted = true
|
||||
} catch (error) {
|
||||
console.warn('[APP][INTERCOM] failed to initialize secure support chat', error)
|
||||
} finally {
|
||||
intercomBooting = false
|
||||
}
|
||||
}
|
||||
|
||||
function shutdownHostingIntercom() {
|
||||
if (!intercomBooted && !intercomBooting) return
|
||||
shutdownIntercom()
|
||||
intercomBooting = false
|
||||
intercomBooted = false
|
||||
}
|
||||
|
||||
watch(
|
||||
sidebarVisible,
|
||||
(visible) => {
|
||||
if (intercomBooted) {
|
||||
window.Intercom?.('update', {
|
||||
horizontal_padding: visible
|
||||
? INTERCOM_APP_SIDEBAR_WIDTH + INTERCOM_DEFAULT_PADDING
|
||||
: INTERCOM_DEFAULT_PADDING,
|
||||
vertical_padding: INTERCOM_DEFAULT_PADDING,
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[hostingRouteActive, credentials],
|
||||
([active]) => {
|
||||
if (active) {
|
||||
void bootIntercom()
|
||||
} else {
|
||||
shutdownHostingIntercom()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(showAd, () => {
|
||||
if (!showAd.value) {
|
||||
@@ -685,7 +786,9 @@ async function handleCommand(e) {
|
||||
if (e.event === 'RunMRPack') {
|
||||
// RunMRPack should directly install a local mrpack given a path
|
||||
if (e.path.endsWith('.mrpack')) {
|
||||
await create_profile_and_install_from_file(e.path).catch(handleError)
|
||||
await create_profile_and_install_from_file(e.path, (createProfile, fileName) =>
|
||||
unknownPackWarningModal.value?.show(createProfile, fileName),
|
||||
).catch(handleError)
|
||||
trackEvent('InstanceCreate', {
|
||||
source: 'CreationModalFileDrop',
|
||||
})
|
||||
@@ -1072,7 +1175,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WindowControls />
|
||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||
<div id="teleports"></div>
|
||||
<div
|
||||
@@ -1108,9 +1210,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
:fetch-existing-instance-names="fetchExistingInstanceNames"
|
||||
:search-modpacks="searchModpacks"
|
||||
:get-project-versions="getProjectVersions"
|
||||
:get-loader-manifest="getLoaderManifest"
|
||||
@create="handleCreate"
|
||||
@browse-modpacks="handleBrowseModpacks"
|
||||
/>
|
||||
<UnknownPackWarningModal ref="unknownPackWarningModal" />
|
||||
<div
|
||||
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.5rem] w-[--left-bar-width]"
|
||||
>
|
||||
@@ -1267,9 +1371,10 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
</ButtonStyled>
|
||||
<div class="flex mr-3">
|
||||
<Suspense>
|
||||
<RunningAppBar />
|
||||
<AppActionBar />
|
||||
</Suspense>
|
||||
</div>
|
||||
<WindowControls />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -180,13 +180,6 @@ img {
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
input[type='button'] {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
@import '@modrinth/assets/omorphia.scss';
|
||||
|
||||
input {
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
<template>
|
||||
<div class="flex gap-4 items-center">
|
||||
<ButtonStyled
|
||||
v-if="hasActiveLoadingBars && !hasVisibleActiveDownloadToasts"
|
||||
color="brand"
|
||||
type="transparent"
|
||||
circular
|
||||
>
|
||||
<button v-tooltip="formatMessage(messages.viewActiveDownloads)" @click="openDownloadToast()">
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div v-if="offline" class="flex items-center gap-1">
|
||||
<UnplugIcon class="text-secondary" />
|
||||
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
|
||||
</div>
|
||||
<div
|
||||
class="flex border-solid border-surface-5 text-sm items-center gap-2 py-1.5 px-3 rounded-xl border"
|
||||
>
|
||||
<template v-if="selectedProcess">
|
||||
<OnlineIndicatorIcon />
|
||||
<div class="text-contrast flex items-center gap-2">
|
||||
<router-link
|
||||
v-tooltip="formatMessage(messages.viewInstance)"
|
||||
:to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`"
|
||||
class="hover:underline"
|
||||
>
|
||||
{{ selectedProcess.profile.name }}
|
||||
</router-link>
|
||||
<Dropdown
|
||||
v-if="currentProcesses.length > 1"
|
||||
placement="bottom"
|
||||
:triggers="['click']"
|
||||
:hide-triggers="['click']"
|
||||
@show="showProfiles = true"
|
||||
@hide="showProfiles = false"
|
||||
>
|
||||
<ButtonStyled type="transparent" circular size="small">
|
||||
<button
|
||||
v-tooltip="
|
||||
showProfiles
|
||||
? formatMessage(messages.hideMoreRunningInstances)
|
||||
: formatMessage(messages.showMoreRunningInstances)
|
||||
"
|
||||
>
|
||||
<DropdownIcon :class="{ 'rotate-180': !!showProfiles }" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template #popper>
|
||||
<div class="flex w-[20rem] max-h-[24rem] flex-col gap-2 overflow-auto">
|
||||
<div
|
||||
v-for="process in currentProcesses"
|
||||
:key="process.uuid"
|
||||
class="flex w-full items-center gap-2 rounded-xl bg-surface-4 p-2 text-sm"
|
||||
>
|
||||
<button
|
||||
v-tooltip.left="
|
||||
process.uuid === selectedProcess.uuid
|
||||
? formatMessage(messages.primaryInstance)
|
||||
: formatMessage(messages.makePrimaryInstance)
|
||||
"
|
||||
class="flex flex-grow items-center gap-2"
|
||||
:class="{
|
||||
'active:scale-95 transition-transform': process.uuid !== selectedProcess.uuid,
|
||||
}"
|
||||
:disabled="process.uuid === selectedProcess.uuid"
|
||||
@click="selectProcess(process)"
|
||||
>
|
||||
<OnlineIndicatorIcon />
|
||||
<span class="mr-auto text-contrast flex items-center gap-2">
|
||||
{{ process.profile.name }}
|
||||
<StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.stopInstance)"
|
||||
class="active:scale-95 flex"
|
||||
@click.stop="stop(process)"
|
||||
>
|
||||
<StopCircleIcon class="text-red size-5" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.viewLogs)"
|
||||
class="active:scale-95 flex"
|
||||
@click.stop="goToTerminal(process.profile.path)"
|
||||
>
|
||||
<TerminalSquareIcon class="text-secondary size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.stopInstance)"
|
||||
class="active:scale-95 flex"
|
||||
@click="stop(selectedProcess)"
|
||||
>
|
||||
<StopCircleIcon class="text-red size-5" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.viewLogs)"
|
||||
class="active:scale-95 flex"
|
||||
@click="goToTerminal()"
|
||||
>
|
||||
<TerminalSquareIcon class="text-secondary size-5" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="size-2 rounded-full bg-secondary" />
|
||||
<span class="text-secondary"> {{ formatMessage(messages.noInstancesRunning) }} </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
OnlineIndicatorIcon,
|
||||
StarIcon,
|
||||
StopCircleIcon,
|
||||
TerminalSquareIcon,
|
||||
UnplugIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
injectPopupNotificationManager,
|
||||
type PopupNotification,
|
||||
type PopupNotificationProgressItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { loading_listener, process_listener } from '@/helpers/events'
|
||||
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
|
||||
import { get_many as getInstances } from '@/helpers/profile.js'
|
||||
import type { LoadingBar } from '@/helpers/state'
|
||||
import { progress_bars_list } from '@/helpers/state'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const popupNotificationManager = injectPopupNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const showProfiles = ref(false)
|
||||
|
||||
interface RunningProcess {
|
||||
uuid: string
|
||||
profile_path: string
|
||||
profile: GameInstance
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
offline: {
|
||||
id: 'app.action-bar.offline',
|
||||
defaultMessage: 'Offline',
|
||||
},
|
||||
viewInstance: {
|
||||
id: 'app.action-bar.view-instance',
|
||||
defaultMessage: 'View instance',
|
||||
},
|
||||
showMoreRunningInstances: {
|
||||
id: 'app.action-bar.show-more-running-instances',
|
||||
defaultMessage: 'Show more running instances',
|
||||
},
|
||||
hideMoreRunningInstances: {
|
||||
id: 'app.action-bar.hide-more-running-instances',
|
||||
defaultMessage: 'Hide more running instances',
|
||||
},
|
||||
primaryInstance: {
|
||||
id: 'app.action-bar.primary-instance',
|
||||
defaultMessage: 'Primary instance',
|
||||
},
|
||||
makePrimaryInstance: {
|
||||
id: 'app.action-bar.make-primary-instance',
|
||||
defaultMessage: 'Make primary instance',
|
||||
},
|
||||
stopInstance: {
|
||||
id: 'app.action-bar.stop-instance',
|
||||
defaultMessage: 'Stop instance',
|
||||
},
|
||||
viewLogs: {
|
||||
id: 'app.action-bar.view-logs',
|
||||
defaultMessage: 'View logs',
|
||||
},
|
||||
noInstancesRunning: {
|
||||
id: 'app.action-bar.no-instances-running',
|
||||
defaultMessage: 'No instances running',
|
||||
},
|
||||
downloadingJava: {
|
||||
id: 'app.action-bar.downloading-java',
|
||||
defaultMessage: 'Downloading Java {version}',
|
||||
},
|
||||
downloads: {
|
||||
id: 'app.action-bar.downloads',
|
||||
defaultMessage: 'Downloads',
|
||||
},
|
||||
viewActiveDownloads: {
|
||||
id: 'app.action-bar.view-active-downloads',
|
||||
defaultMessage: 'View active downloads',
|
||||
},
|
||||
})
|
||||
|
||||
const currentProcesses = ref<RunningProcess[]>([])
|
||||
const selectedProcess = ref<RunningProcess | undefined>()
|
||||
|
||||
const refresh = async () => {
|
||||
const processes = ((await getRunningProcesses().catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})) ?? []) as Array<{ uuid: string; profile_path: string }>
|
||||
const paths = processes.map((process) => process.profile_path)
|
||||
const profiles: GameInstance[] = await getInstances(paths).catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
|
||||
currentProcesses.value = processes
|
||||
.map((process) => {
|
||||
const profile = profiles.find((item) => process.profile_path === item.path)
|
||||
if (!profile) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...process,
|
||||
profile,
|
||||
}
|
||||
})
|
||||
.filter((process): process is RunningProcess => process !== null)
|
||||
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) {
|
||||
selectedProcess.value = currentProcesses.value[0]
|
||||
}
|
||||
}
|
||||
|
||||
await refresh()
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
function handleOffline() {
|
||||
offline.value = true
|
||||
}
|
||||
function handleOnline() {
|
||||
offline.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('offline', handleOffline)
|
||||
window.addEventListener('online', handleOnline)
|
||||
})
|
||||
|
||||
const unlistenProcess = await process_listener(async () => {
|
||||
await refresh()
|
||||
})
|
||||
|
||||
const stop = async (process: RunningProcess) => {
|
||||
try {
|
||||
await killProcess(process.uuid).catch(handleError)
|
||||
|
||||
trackEvent('InstanceStop', {
|
||||
loader: process.profile.loader,
|
||||
game_version: process.profile.game_version,
|
||||
source: 'AppBar',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function goToTerminal(path?: string) {
|
||||
const selectedPath = path ?? selectedProcess.value?.profile.path
|
||||
if (!selectedPath) {
|
||||
return
|
||||
}
|
||||
router.push(`/instance/${encodeURIComponent(selectedPath)}/logs`)
|
||||
}
|
||||
|
||||
const currentLoadingBars = ref<LoadingBar[]>([])
|
||||
const notificationId = ref<string | number | null>(null)
|
||||
const dismissed = ref(false)
|
||||
|
||||
function getLoadingBarKey(loadingBar: LoadingBar): string {
|
||||
return `${loadingBar.loading_bar_uuid ?? loadingBar.id}`
|
||||
}
|
||||
|
||||
function getLoadingProgress(loadingBar: LoadingBar): number {
|
||||
if (!loadingBar.total || loadingBar.total <= 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.max(0, Math.min(1, (loadingBar.current ?? 0) / (loadingBar.total ?? 0)))
|
||||
}
|
||||
|
||||
function getLoadingText(loadingBar: LoadingBar): string {
|
||||
const percent = Math.floor(getLoadingProgress(loadingBar) * 100)
|
||||
return loadingBar.message ? `${percent}% ${loadingBar.message}` : `${percent}%`
|
||||
}
|
||||
|
||||
function getNotification(): PopupNotification | null {
|
||||
if (!notificationId.value) {
|
||||
return null
|
||||
}
|
||||
const notification = popupNotificationManager
|
||||
.getNotifications()
|
||||
.find((notification) => notification.id === notificationId.value)
|
||||
return notification ?? null
|
||||
}
|
||||
|
||||
function removeNotification(): void {
|
||||
if (!notificationId.value) {
|
||||
return
|
||||
}
|
||||
popupNotificationManager.removeNotification(notificationId.value)
|
||||
notificationId.value = null
|
||||
}
|
||||
|
||||
function buildDownloadItems(): PopupNotificationProgressItem[] {
|
||||
return currentLoadingBars.value.map((bar) => ({
|
||||
id: getLoadingBarKey(bar),
|
||||
title: bar.title ?? '',
|
||||
text: getLoadingText(bar),
|
||||
progress: getLoadingProgress(bar),
|
||||
waiting: !bar.total || bar.total <= 0,
|
||||
}))
|
||||
}
|
||||
|
||||
const hasVisibleActiveDownloadToasts = computed(() => !!getNotification())
|
||||
const hasActiveLoadingBars = computed(() => currentLoadingBars.value.length > 0)
|
||||
|
||||
function updateNotification(resummon = false): void {
|
||||
if (resummon) {
|
||||
dismissed.value = false
|
||||
}
|
||||
|
||||
if (currentLoadingBars.value.length === 0) {
|
||||
removeNotification()
|
||||
dismissed.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (notificationId.value && !getNotification()) {
|
||||
notificationId.value = null
|
||||
dismissed.value = true
|
||||
}
|
||||
|
||||
if (dismissed.value && !resummon) {
|
||||
return
|
||||
}
|
||||
|
||||
let notif = getNotification()
|
||||
const progressItems = buildDownloadItems()
|
||||
|
||||
if (notif) {
|
||||
notif.title = formatMessage(messages.downloads)
|
||||
notif.text = undefined
|
||||
notif.progressItems = progressItems
|
||||
notif.progress = undefined
|
||||
notif.waiting = undefined
|
||||
} else {
|
||||
notif = popupNotificationManager.addPopupNotification({
|
||||
title: formatMessage(messages.downloads),
|
||||
type: 'download',
|
||||
autoCloseMs: null,
|
||||
progressItems,
|
||||
})
|
||||
notificationId.value = notif.id
|
||||
}
|
||||
}
|
||||
|
||||
function formatLoadingBars(loadingBar: LoadingBar): LoadingBar {
|
||||
const formatted = { ...loadingBar }
|
||||
if (formatted.bar_type?.type === 'java_download') {
|
||||
formatted.title = formatMessage(messages.downloadingJava, {
|
||||
version: formatted.bar_type.version,
|
||||
})
|
||||
}
|
||||
if (formatted.bar_type?.profile_path) {
|
||||
formatted.title = formatted.bar_type.profile_path
|
||||
}
|
||||
if (formatted.bar_type?.pack_name) {
|
||||
formatted.title = formatted.bar_type.pack_name
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
async function refreshLoadingBars() {
|
||||
const bars: Record<string, LoadingBar> = await progress_bars_list().catch((error) => {
|
||||
handleError(error)
|
||||
return {}
|
||||
})
|
||||
|
||||
currentLoadingBars.value = Object.values(bars)
|
||||
.map(formatLoadingBars)
|
||||
.filter((bar) => bar?.bar_type?.type !== 'launcher_update')
|
||||
|
||||
currentLoadingBars.value.sort((a, b) => {
|
||||
const aKey = `${a.loading_bar_uuid ?? a.id ?? ''}`
|
||||
const bKey = `${b.loading_bar_uuid ?? b.id ?? ''}`
|
||||
return aKey.localeCompare(bKey)
|
||||
})
|
||||
|
||||
updateNotification()
|
||||
}
|
||||
|
||||
await refreshLoadingBars()
|
||||
|
||||
const unlistenLoading = await loading_listener(async () => {
|
||||
await refreshLoadingBars()
|
||||
})
|
||||
|
||||
function openDownloadToast() {
|
||||
updateNotification(true)
|
||||
}
|
||||
|
||||
function selectProcess(process: RunningProcess) {
|
||||
selectedProcess.value = process
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
removeNotification()
|
||||
dismissed.value = false
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
window.removeEventListener('online', handleOnline)
|
||||
unlistenProcess()
|
||||
unlistenLoading()
|
||||
})
|
||||
</script>
|
||||
@@ -1,476 +0,0 @@
|
||||
<template>
|
||||
<div class="action-groups">
|
||||
<ButtonStyled v-if="currentLoadingBars.length > 0" color="brand" type="transparent" circular>
|
||||
<button ref="infoButton" @click="toggleCard()">
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div v-if="offline" class="status">
|
||||
<UnplugIcon />
|
||||
<div class="running-text">
|
||||
<span> Offline </span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedProcess" class="status">
|
||||
<span class="circle running" />
|
||||
<div ref="profileButton" class="running-text">
|
||||
<router-link
|
||||
class="text-primary"
|
||||
:to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`"
|
||||
>
|
||||
{{ selectedProcess.profile.name }}
|
||||
</router-link>
|
||||
<div
|
||||
v-if="currentProcesses.length > 1"
|
||||
class="arrow button-base"
|
||||
:class="{ rotate: showProfiles }"
|
||||
@click="toggleProfiles()"
|
||||
>
|
||||
<DropdownIcon />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="'Stop instance'"
|
||||
icon-only
|
||||
class="icon-button stop"
|
||||
@click="stop(selectedProcess)"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</Button>
|
||||
<Button v-tooltip="'View logs'" icon-only class="icon-button" @click="goToTerminal()">
|
||||
<TerminalSquareIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="status">
|
||||
<span class="circle stopped" />
|
||||
<span class="running-text"> No instances running </span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="download">
|
||||
<Card v-if="showCard === true && currentLoadingBars.length > 0" ref="card" class="info-card">
|
||||
<div v-for="loadingBar in currentLoadingBars" :key="loadingBar.id" class="info-text">
|
||||
<h3 class="info-title">
|
||||
{{ loadingBar.title }}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" />
|
||||
<div class="row">
|
||||
{{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}%
|
||||
{{ loadingBar.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</transition>
|
||||
<transition name="download">
|
||||
<Card
|
||||
v-if="showProfiles === true && currentProcesses.length > 0"
|
||||
ref="profiles"
|
||||
class="profile-card"
|
||||
>
|
||||
<Button
|
||||
v-for="process in currentProcesses"
|
||||
:key="process.uuid"
|
||||
class="profile-button"
|
||||
@click="selectProcess(process)"
|
||||
>
|
||||
<div class="text"><span class="circle running" /> {{ process.profile.name }}</div>
|
||||
<Button
|
||||
v-tooltip="'Stop instance'"
|
||||
icon-only
|
||||
class="icon-button stop"
|
||||
@click.stop="stop(process)"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="'View logs'"
|
||||
icon-only
|
||||
class="icon-button"
|
||||
@click.stop="goToTerminal(process.profile.path)"
|
||||
>
|
||||
<TerminalSquareIcon />
|
||||
</Button>
|
||||
</Button>
|
||||
</Card>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
StopCircleIcon,
|
||||
TerminalSquareIcon,
|
||||
UnplugIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button, ButtonStyled, Card, injectNotificationManager } from '@modrinth/ui'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { loading_listener, process_listener } from '@/helpers/events'
|
||||
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
|
||||
import { get_many } from '@/helpers/profile.js'
|
||||
import { progress_bars_list } from '@/helpers/state.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const router = useRouter()
|
||||
const card = ref(null)
|
||||
const profiles = ref(null)
|
||||
const infoButton = ref(null)
|
||||
const profileButton = ref(null)
|
||||
const showCard = ref(false)
|
||||
|
||||
const showProfiles = ref(false)
|
||||
|
||||
const currentProcesses = ref([])
|
||||
const selectedProcess = ref()
|
||||
|
||||
const refresh = async () => {
|
||||
const processes = await getRunningProcesses().catch(handleError)
|
||||
const profiles = await get_many(processes.map((x) => x.profile_path)).catch(handleError)
|
||||
|
||||
currentProcesses.value = processes.map((x) => ({
|
||||
profile: profiles.find((prof) => x.profile_path === prof.path),
|
||||
...x,
|
||||
}))
|
||||
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) {
|
||||
selectedProcess.value = currentProcesses.value[0]
|
||||
}
|
||||
}
|
||||
|
||||
await refresh()
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
window.addEventListener('offline', () => {
|
||||
offline.value = true
|
||||
})
|
||||
window.addEventListener('online', () => {
|
||||
offline.value = false
|
||||
})
|
||||
|
||||
const unlistenProcess = await process_listener(async () => {
|
||||
await refresh()
|
||||
})
|
||||
|
||||
const stop = async (process) => {
|
||||
try {
|
||||
await killProcess(process.uuid).catch(handleError)
|
||||
|
||||
trackEvent('InstanceStop', {
|
||||
loader: process.profile.loader,
|
||||
game_version: process.profile.game_version,
|
||||
source: 'AppBar',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const goToTerminal = (path) => {
|
||||
router.push(`/instance/${encodeURIComponent(path ?? selectedProcess.value.profile.path)}/logs`)
|
||||
}
|
||||
|
||||
const currentLoadingBars = ref([])
|
||||
|
||||
const refreshInfo = async () => {
|
||||
const currentLoadingBarCount = currentLoadingBars.value.length
|
||||
currentLoadingBars.value = Object.values(await progress_bars_list().catch(handleError))
|
||||
.map((x) => {
|
||||
if (x.bar_type.type === 'java_download') {
|
||||
x.title = 'Downloading Java ' + x.bar_type.version
|
||||
}
|
||||
if (x.bar_type.profile_path) {
|
||||
x.title = x.bar_type.profile_path
|
||||
}
|
||||
if (x.bar_type.pack_name) {
|
||||
x.title = x.bar_type.pack_name
|
||||
}
|
||||
|
||||
return x
|
||||
})
|
||||
.filter((bar) => bar?.bar_type?.type !== 'launcher_update')
|
||||
|
||||
currentLoadingBars.value.sort((a, b) => {
|
||||
if (a.loading_bar_uuid < b.loading_bar_uuid) {
|
||||
return -1
|
||||
}
|
||||
if (a.loading_bar_uuid > b.loading_bar_uuid) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
if (currentLoadingBars.value.length === 0) {
|
||||
showCard.value = false
|
||||
} else if (currentLoadingBarCount < currentLoadingBars.value.length) {
|
||||
showCard.value = true
|
||||
}
|
||||
}
|
||||
|
||||
await refreshInfo()
|
||||
const unlistenLoading = await loading_listener(async () => {
|
||||
await refreshInfo()
|
||||
})
|
||||
|
||||
const selectProcess = (process) => {
|
||||
selectedProcess.value = process
|
||||
showProfiles.value = false
|
||||
}
|
||||
|
||||
const handleClickOutsideCard = (event) => {
|
||||
const elements = document.elementsFromPoint(event.clientX, event.clientY)
|
||||
if (
|
||||
card.value &&
|
||||
card.value.$el !== event.target &&
|
||||
!elements.includes(card.value.$el) &&
|
||||
infoButton.value &&
|
||||
!infoButton.value.contains(event.target)
|
||||
) {
|
||||
showCard.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutsideProfile = (event) => {
|
||||
const elements = document.elementsFromPoint(event.clientX, event.clientY)
|
||||
if (
|
||||
profiles.value &&
|
||||
profiles.value.$el !== event.target &&
|
||||
!elements.includes(profiles.value.$el) &&
|
||||
!profileButton.value.contains(event.target)
|
||||
) {
|
||||
showProfiles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCard = async () => {
|
||||
showCard.value = !showCard.value
|
||||
showProfiles.value = false
|
||||
await refreshInfo()
|
||||
}
|
||||
|
||||
const toggleProfiles = async () => {
|
||||
if (currentProcesses.value.length === 1) return
|
||||
showProfiles.value = !showProfiles.value
|
||||
showCard.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', handleClickOutsideCard)
|
||||
window.addEventListener('click', handleClickOutsideProfile)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', handleClickOutsideCard)
|
||||
window.removeEventListener('click', handleClickOutsideProfile)
|
||||
unlistenProcess()
|
||||
unlistenLoading()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.action-groups {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&.rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-divider);
|
||||
padding: var(--gap-sm) var(--gap-lg);
|
||||
}
|
||||
|
||||
.running-text {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-xs);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-ms-user-select: none; /* IE 10 and IE 11 */
|
||||
user-select: none;
|
||||
|
||||
&.clickable:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 0.25rem;
|
||||
|
||||
&.running {
|
||||
background-color: var(--color-brand);
|
||||
}
|
||||
|
||||
&.stopped {
|
||||
background-color: var(--color-base);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
box-shadow: none;
|
||||
width: 1.25rem !important;
|
||||
height: 1.25rem !important;
|
||||
|
||||
svg {
|
||||
min-width: 1.25rem;
|
||||
}
|
||||
|
||||
&.stop {
|
||||
color: var(--color-red);
|
||||
}
|
||||
}
|
||||
|
||||
.info-card {
|
||||
position: absolute;
|
||||
top: 3.5rem;
|
||||
right: 2rem;
|
||||
z-index: 9;
|
||||
width: 20rem;
|
||||
background-color: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-raised);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow: auto;
|
||||
transition: all 0.2s ease-in-out;
|
||||
border: 1px solid var(--color-divider);
|
||||
|
||||
&.hidden {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-option {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
:hover {
|
||||
background-color: var(--color-raised-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: block;
|
||||
|
||||
:deep(svg) {
|
||||
left: 1rem;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.download-enter-active,
|
||||
.download-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.download-enter-from,
|
||||
.download-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
width: 100%;
|
||||
background-color: var(--color-raised-bg);
|
||||
box-shadow: none;
|
||||
|
||||
.text {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
position: absolute;
|
||||
top: 3.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 9;
|
||||
background-color: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-raised);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
transition: all 0.2s ease-in-out;
|
||||
border: 1px solid var(--color-divider);
|
||||
padding: var(--gap-md);
|
||||
|
||||
&.hidden {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,28 @@
|
||||
<template>
|
||||
<section v-if="showControls" class="window-controls" data-tauri-drag-region-exclude>
|
||||
<section
|
||||
v-if="showControls"
|
||||
class="flex items-center gap-2 mr-1.5"
|
||||
data-tauri-drag-region-exclude
|
||||
>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button class="titlebar-button" @click="() => getCurrentWindow().minimize()">
|
||||
<button class="relative expanded-button" @click="() => getCurrentWindow().minimize()">
|
||||
<MinimizeIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button class="titlebar-button" @click="() => getCurrentWindow().toggleMaximize()">
|
||||
<button class="relative expanded-button" @click="() => getCurrentWindow().toggleMaximize()">
|
||||
<RestoreIcon v-if="isMaximized" />
|
||||
<MaximizeIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button class="titlebar-button close" @click="handleClose">
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
color="red"
|
||||
color-fill="none"
|
||||
hover-color-fill="background"
|
||||
circular
|
||||
>
|
||||
<button class="relative expanded-button" @click="handleClose">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -24,29 +34,24 @@ import { MaximizeIcon, MinimizeIcon, RestoreIcon, XIcon } from '@modrinth/assets
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { get as getSettings } from '@/helpers/settings.ts'
|
||||
import { getOS } from '@/helpers/utils.js'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
const WINDOW_CONTROLS_WIDTH = '8rem'
|
||||
const themeStore = useTheming()
|
||||
|
||||
const nativeDecorations = ref(true)
|
||||
const isMaximized = ref(false)
|
||||
const os = ref('')
|
||||
|
||||
const showControls = computed(() => !nativeDecorations.value && os.value !== 'MacOS')
|
||||
watch(
|
||||
showControls,
|
||||
(visible) => {
|
||||
if (typeof document === 'undefined') return
|
||||
if (visible) {
|
||||
document.documentElement.style.setProperty('--window-controls-width', WINDOW_CONTROLS_WIDTH)
|
||||
} else {
|
||||
document.documentElement.style.removeProperty('--window-controls-width')
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
const alwaysShowAppControls = computed(() => themeStore.getFeatureFlag('always_show_app_controls'))
|
||||
|
||||
const showControls = computed(
|
||||
() =>
|
||||
alwaysShowAppControls.value ||
|
||||
(!nativeDecorations.value && (os.value === 'Windows' || os.value === 'Linux')),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -67,7 +72,6 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
unlisten()
|
||||
document.documentElement.style.removeProperty('--window-controls-width')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,23 +80,10 @@ const handleClose = async () => {
|
||||
await getCurrentWindow().close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.window-controls {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: var(--top-bar-height, 3rem);
|
||||
padding-right: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
|
||||
.titlebar-button.close:hover {
|
||||
background-color: var(--color-red);
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
<style scoped>
|
||||
.expanded-button::before {
|
||||
inset: -6px;
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" :on-hide="reset">
|
||||
<div class="max-w-[31rem] flex flex-col gap-6">
|
||||
<Admonition
|
||||
type="warning"
|
||||
:header="formatMessage(messages.warningTitle)"
|
||||
:body="formatMessage(messages.warningBody)"
|
||||
/>
|
||||
<div v-if="fileName" class="overflow-x-auto whitespace-nowrap text-sm text-secondary">
|
||||
{{ fileName }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="mt-0 leading-tight">
|
||||
{{ formatMessage(messages.body) }}
|
||||
</p>
|
||||
<p class="text-orange font-semibold mb-0 leading-tight">
|
||||
{{ formatMessage(messages.malwareStatement) }}
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox v-model="dontShowAgain" :label="formatMessage(messages.dontShowAgain)" />
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="cancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button :disabled="isProceeding" @click="proceed">
|
||||
<SpinnerIcon v-if="isProceeding" class="animate-spin" />
|
||||
<CircleArrowRightIcon v-else />
|
||||
{{ formatMessage(messages.installAnyway) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CircleArrowRightIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings'
|
||||
import { useTheming } from '@/store/state'
|
||||
import type { FeatureFlag } from '@/store/theme.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const themeStore = useTheming()
|
||||
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
|
||||
|
||||
const dontShowAgain = ref(false)
|
||||
const modal = useTemplateRef('modal')
|
||||
const onProceed = ref<() => Promise<void>>()
|
||||
const isProceeding = ref(false)
|
||||
const fileName = ref('')
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'unknown-pack-warning-modal.header',
|
||||
defaultMessage: 'Confirm installation',
|
||||
},
|
||||
warningTitle: {
|
||||
id: 'unknown-pack-warning-modal.warning.title',
|
||||
defaultMessage: 'Unknown file warning',
|
||||
},
|
||||
warningBody: {
|
||||
id: 'unknown-pack-warning-modal.warning.body',
|
||||
defaultMessage: `We couldn't find this file on Modrinth. We strongly recommend only installing files from sources you trust.`,
|
||||
},
|
||||
body: {
|
||||
id: 'unknown-pack-warning-modal.body',
|
||||
defaultMessage: `A file is only reviewed if it’s uploaded to Modrinth, regardless of its file format (including .mrpack).`,
|
||||
},
|
||||
malwareStatement: {
|
||||
id: 'unknown-pack-warning-modal.malware-statement',
|
||||
defaultMessage: `Malware is often distributed through modpack files by sharing them on platforms like Discord.`,
|
||||
},
|
||||
dontShowAgain: {
|
||||
id: 'unknown-pack-warning-modal.dont-show-again',
|
||||
defaultMessage: `Don't show this warning again`,
|
||||
},
|
||||
installAnyway: {
|
||||
id: 'unknown-pack-warning-modal.install-anyway',
|
||||
defaultMessage: `Install anyway`,
|
||||
},
|
||||
})
|
||||
|
||||
function show(createInstance: () => Promise<void>, selectedFileName = '') {
|
||||
onProceed.value = createInstance
|
||||
fileName.value = selectedFileName
|
||||
dontShowAgain.value = false
|
||||
|
||||
if (themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
|
||||
// noinspection ES6MissingAwait
|
||||
createInstance()
|
||||
return
|
||||
}
|
||||
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
onProceed.value = undefined
|
||||
fileName.value = ''
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
async function proceed() {
|
||||
if (!onProceed.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (dontShowAgain.value) {
|
||||
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = true
|
||||
const settings = await getSettings()
|
||||
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = true
|
||||
await setSettings(settings)
|
||||
}
|
||||
|
||||
const createInstance = onProceed.value
|
||||
modal.value?.hide()
|
||||
// noinspection ES6MissingAwait
|
||||
createInstance()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@@ -31,7 +31,7 @@ const props = defineProps({
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
defineExpose({
|
||||
show: (e: MouseEvent) => {
|
||||
show: (e?: MouseEvent) => {
|
||||
modal.value?.show(e)
|
||||
},
|
||||
hide: () => {
|
||||
|
||||
@@ -1,13 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { Combobox, ThemeSelector, Toggle } from '@modrinth/ui'
|
||||
import { Combobox, defineMessages, ThemeSelector, Toggle, useVIntl } from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { getOS } from '@/helpers/utils'
|
||||
import { useTheming } from '@/store/state'
|
||||
import type { ColorTheme } from '@/store/theme.ts'
|
||||
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
|
||||
|
||||
const themeStore = useTheming()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const worldsInHomeFeatureFlag = 'worlds_in_home' as FeatureFlag
|
||||
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
|
||||
|
||||
const messages = defineMessages({
|
||||
colorThemeTitle: {
|
||||
id: 'app.appearance-settings.color-theme.title',
|
||||
defaultMessage: 'Color theme',
|
||||
},
|
||||
colorThemeDescription: {
|
||||
id: 'app.appearance-settings.color-theme.description',
|
||||
defaultMessage: 'Select your preferred color theme for Modrinth App.',
|
||||
},
|
||||
advancedRenderingTitle: {
|
||||
id: 'app.appearance-settings.advanced-rendering.title',
|
||||
defaultMessage: 'Advanced rendering',
|
||||
},
|
||||
advancedRenderingDescription: {
|
||||
id: 'app.appearance-settings.advanced-rendering.description',
|
||||
defaultMessage:
|
||||
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
|
||||
},
|
||||
hideNametagTitle: {
|
||||
id: 'app.appearance-settings.hide-nametag.title',
|
||||
defaultMessage: 'Hide nametag',
|
||||
},
|
||||
hideNametagDescription: {
|
||||
id: 'app.appearance-settings.hide-nametag.description',
|
||||
defaultMessage: 'Disables the nametag above your player on the skins page.',
|
||||
},
|
||||
nativeDecorationsTitle: {
|
||||
id: 'app.appearance-settings.native-decorations.title',
|
||||
defaultMessage: 'Native decorations',
|
||||
},
|
||||
nativeDecorationsDescription: {
|
||||
id: 'app.appearance-settings.native-decorations.description',
|
||||
defaultMessage: 'Use system window frame (app restart required).',
|
||||
},
|
||||
minimizeLauncherTitle: {
|
||||
id: 'app.appearance-settings.minimize-launcher.title',
|
||||
defaultMessage: 'Minimize launcher',
|
||||
},
|
||||
minimizeLauncherDescription: {
|
||||
id: 'app.appearance-settings.minimize-launcher.description',
|
||||
defaultMessage: 'Minimize the launcher when a Minecraft process starts.',
|
||||
},
|
||||
defaultLandingPageTitle: {
|
||||
id: 'app.appearance-settings.default-landing-page.title',
|
||||
defaultMessage: 'Default landing page',
|
||||
},
|
||||
defaultLandingPageDescription: {
|
||||
id: 'app.appearance-settings.default-landing-page.description',
|
||||
defaultMessage: 'Change the page to which the launcher opens on.',
|
||||
},
|
||||
defaultLandingPageHome: {
|
||||
id: 'app.appearance-settings.default-landing-page.home',
|
||||
defaultMessage: 'Home',
|
||||
},
|
||||
defaultLandingPageLibrary: {
|
||||
id: 'app.appearance-settings.default-landing-page.library',
|
||||
defaultMessage: 'Library',
|
||||
},
|
||||
selectOption: {
|
||||
id: 'app.appearance-settings.select-option',
|
||||
defaultMessage: 'Select an option',
|
||||
},
|
||||
jumpBackIntoWorldsTitle: {
|
||||
id: 'app.appearance-settings.jump-back-into-worlds.title',
|
||||
defaultMessage: 'Jump back into worlds',
|
||||
},
|
||||
jumpBackIntoWorldsDescription: {
|
||||
id: 'app.appearance-settings.jump-back-into-worlds.description',
|
||||
defaultMessage: 'Includes recent worlds in the "Jump back in" section on the Home page.',
|
||||
},
|
||||
toggleSidebarTitle: {
|
||||
id: 'app.appearance-settings.toggle-sidebar.title',
|
||||
defaultMessage: 'Toggle sidebar',
|
||||
},
|
||||
toggleSidebarDescription: {
|
||||
id: 'app.appearance-settings.toggle-sidebar.description',
|
||||
defaultMessage: 'Enables the ability to toggle the sidebar.',
|
||||
},
|
||||
unknownPackWarningTitle: {
|
||||
id: 'app.appearance-settings.unknown-pack-warning.title',
|
||||
defaultMessage: 'Warn me before installing unknown modpacks',
|
||||
},
|
||||
unknownPackWarningDescription: {
|
||||
id: 'app.appearance-settings.unknown-pack-warning.description',
|
||||
defaultMessage:
|
||||
"If you attempt to install a Modrinth Pack file (.mrpack) that isn't hosted on Modrinth, we'll make sure you understand the risks before installing it.",
|
||||
},
|
||||
})
|
||||
|
||||
const os = ref(await getOS())
|
||||
const settings = ref(await get())
|
||||
@@ -21,8 +114,10 @@ watch(
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">Color theme</h2>
|
||||
<p class="m-0 mt-1">Select your preferred color theme for Modrinth App.</p>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.colorThemeTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.colorThemeDescription) }}</p>
|
||||
|
||||
<ThemeSelector
|
||||
:update-color-theme="
|
||||
@@ -38,10 +133,11 @@ watch(
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">Advanced rendering</h2>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.advancedRenderingTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">
|
||||
Enables advanced rendering such as blur effects that may cause performance issues without
|
||||
hardware-accelerated rendering.
|
||||
{{ formatMessage(messages.advancedRenderingDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -59,55 +155,94 @@ watch(
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">Hide nametag</h2>
|
||||
<p class="m-0 mt-1">Disables the nametag above your player on the skins page.</p>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.hideNametagTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.hideNametagDescription) }}</p>
|
||||
</div>
|
||||
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
|
||||
</div>
|
||||
|
||||
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">Native decorations</h2>
|
||||
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.nativeDecorationsTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.nativeDecorationsDescription) }}</p>
|
||||
</div>
|
||||
<Toggle id="native-decorations" v-model="settings.native_decorations" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">Minimize launcher</h2>
|
||||
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.minimizeLauncherTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.minimizeLauncherDescription) }}</p>
|
||||
</div>
|
||||
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">Default landing page</h2>
|
||||
<p class="m-0 mt-1">Change the page to which the launcher opens on.</p>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.defaultLandingPageTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.defaultLandingPageDescription) }}</p>
|
||||
</div>
|
||||
<Combobox
|
||||
id="opening-page"
|
||||
v-model="settings.default_page"
|
||||
name="Opening page dropdown"
|
||||
class="max-w-40"
|
||||
:options="['Home', 'Library'].map((v) => ({ value: v, label: v }))"
|
||||
:options="[
|
||||
{
|
||||
value: 'Home',
|
||||
label: formatMessage(messages.defaultLandingPageHome),
|
||||
},
|
||||
{
|
||||
value: 'Library',
|
||||
label: formatMessage(messages.defaultLandingPageLibrary),
|
||||
},
|
||||
]"
|
||||
:display-value="settings.default_page ?? 'Select an option'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">Jump back into worlds</h2>
|
||||
<p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.jumpBackIntoWorldsTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.jumpBackIntoWorldsDescription) }}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="themeStore.getFeatureFlag('worlds_in_home')"
|
||||
:model-value="themeStore.getFeatureFlag(worldsInHomeFeatureFlag)"
|
||||
@update:model-value="
|
||||
() => {
|
||||
const newValue = !themeStore.getFeatureFlag('worlds_in_home')
|
||||
themeStore.featureFlags['worlds_in_home'] = newValue
|
||||
settings.feature_flags['worlds_in_home'] = newValue
|
||||
const newValue = !themeStore.getFeatureFlag(worldsInHomeFeatureFlag)
|
||||
themeStore.featureFlags[worldsInHomeFeatureFlag] = newValue
|
||||
settings.feature_flags[worldsInHomeFeatureFlag] = newValue
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.unknownPackWarningTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.unknownPackWarningDescription) }}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
const warnBeforeUnknownPackInstall = !!e
|
||||
const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
|
||||
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = skipUnknownPackWarning
|
||||
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = skipUnknownPackWarning
|
||||
}
|
||||
"
|
||||
/>
|
||||
@@ -115,8 +250,10 @@ watch(
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">Toggle sidebar</h2>
|
||||
<p class="m-0 mt-1">Enables the ability to toggle the sidebar.</p>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.toggleSidebarTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.toggleSidebarDescription) }}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="toggle-sidebar"
|
||||
|
||||
@@ -8,6 +8,7 @@ interface PackProfileCreator {
|
||||
gameVersion: string
|
||||
modloader: InstanceLoader
|
||||
loaderVersion: string | null
|
||||
unknownFile: boolean
|
||||
}
|
||||
|
||||
interface PackLocationVersionId {
|
||||
@@ -69,7 +70,10 @@ export async function install_to_existing_profile(
|
||||
return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
|
||||
}
|
||||
|
||||
export async function create_profile_and_install_from_file(path: string): Promise<void> {
|
||||
export async function create_profile_and_install_from_file(
|
||||
path: string,
|
||||
showUnknownPackWarningModal?: (createProfile: () => Promise<void>, fileName: string) => void,
|
||||
): Promise<void> {
|
||||
const location: PackLocationFile = {
|
||||
type: 'fromFile',
|
||||
path,
|
||||
@@ -78,13 +82,24 @@ export async function create_profile_and_install_from_file(path: string): Promis
|
||||
'plugin:pack|pack_get_profile_from_pack',
|
||||
{ location },
|
||||
)
|
||||
const profile = await create(
|
||||
profile_creator.name,
|
||||
profile_creator.gameVersion,
|
||||
profile_creator.modloader,
|
||||
profile_creator.loaderVersion,
|
||||
null,
|
||||
true,
|
||||
)
|
||||
return await invoke('plugin:pack|pack_install', { location, profile })
|
||||
|
||||
const createProfile = async () => {
|
||||
const profile = await create(
|
||||
profile_creator.name,
|
||||
profile_creator.gameVersion,
|
||||
profile_creator.modloader,
|
||||
profile_creator.loaderVersion,
|
||||
null,
|
||||
true,
|
||||
)
|
||||
await invoke('plugin:pack|pack_install', { location, profile })
|
||||
}
|
||||
|
||||
if (profile_creator.unknownFile && showUnknownPackWarningModal) {
|
||||
const splitPath = path.split(/[\\/]/)
|
||||
const fileName = splitPath ? splitPath[splitPath.length - 1] : path
|
||||
showUnknownPackWarningModal(createProfile, fileName)
|
||||
} else {
|
||||
await createProfile()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,46 @@
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export interface LoadingBarType {
|
||||
type?: string
|
||||
version?: string
|
||||
profile_path?: string
|
||||
pack_name?: string
|
||||
}
|
||||
|
||||
export interface LoadingBar {
|
||||
id?: string | number
|
||||
loading_bar_uuid?: string | number
|
||||
title?: string
|
||||
message?: string
|
||||
current?: number
|
||||
total?: number
|
||||
bar_type?: LoadingBarType
|
||||
}
|
||||
|
||||
export type OpeningCommandEvent =
|
||||
| 'RunMRPack'
|
||||
| 'InstallServer'
|
||||
| 'InstallVersion'
|
||||
| 'InstallMod'
|
||||
| 'InstallModpack'
|
||||
| string
|
||||
|
||||
export interface OpeningCommand {
|
||||
event: OpeningCommandEvent
|
||||
id?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
// Initialize the theseus API state
|
||||
// This should be called during the initializion/opening of the launcher
|
||||
export async function initialize_state() {
|
||||
return await invoke('initialize_state')
|
||||
return await invoke<void>('initialize_state')
|
||||
}
|
||||
|
||||
// Gets active progress bars
|
||||
export async function progress_bars_list() {
|
||||
return await invoke('plugin:utils|progress_bars_list')
|
||||
return await invoke<Record<string, LoadingBar>>('plugin:utils|progress_bars_list')
|
||||
}
|
||||
|
||||
// Get opening command
|
||||
@@ -21,5 +52,5 @@ export async function progress_bars_list() {
|
||||
// This should be called once and only when the app is done booting up and ready to receive a command
|
||||
// Returns a Command struct- see events.js
|
||||
export async function get_opening_command() {
|
||||
return await invoke('plugin:utils|get_opening_command')
|
||||
return await invoke<OpeningCommand | null>('plugin:utils|get_opening_command')
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
"message": "Přidat servery do instance"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Přidat do instance"
|
||||
"message": "Přidat do instalace"
|
||||
},
|
||||
"app.browse.add-to-instance-name": {
|
||||
"message": "Přidat do {instanceName}"
|
||||
@@ -84,7 +84,7 @@
|
||||
"message": "Modpack už je nainstalovaný"
|
||||
},
|
||||
"app.instance.modpack-already-installed.instance": {
|
||||
"message": "Instance"
|
||||
"message": "Instalace"
|
||||
},
|
||||
"app.instance.mods.content-type-project": {
|
||||
"message": "projekt"
|
||||
@@ -117,7 +117,7 @@
|
||||
"message": "Opravdu si jsi jistý, že chceš navždy smazat tenhle svět?"
|
||||
},
|
||||
"app.instance.worlds.filter-modded": {
|
||||
"message": "Modováno"
|
||||
"message": "Módováno"
|
||||
},
|
||||
"app.instance.worlds.filter-offline": {
|
||||
"message": "Offline"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"message": "Authentifizierungsserver sind nicht erreichbar"
|
||||
},
|
||||
"app.browse.add-server-to-instance": {
|
||||
"message": "Server zur Instanz hinzufügen"
|
||||
"message": "Server zu Instanz hinzufügen"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Server zu deiner Instanz hinzufügen"
|
||||
|
||||
@@ -1,4 +1,103 @@
|
||||
{
|
||||
"app.action-bar.downloading-java": {
|
||||
"message": "Downloading Java {version}"
|
||||
},
|
||||
"app.action-bar.downloads": {
|
||||
"message": "Downloads"
|
||||
},
|
||||
"app.action-bar.hide-more-running-instances": {
|
||||
"message": "Hide more running instances"
|
||||
},
|
||||
"app.action-bar.make-primary-instance": {
|
||||
"message": "Make primary instance"
|
||||
},
|
||||
"app.action-bar.no-instances-running": {
|
||||
"message": "No instances running"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "Primary instance"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "Show more running instances"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Stop instance"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "View active downloads"
|
||||
},
|
||||
"app.action-bar.view-instance": {
|
||||
"message": "View instance"
|
||||
},
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "View logs"
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.description": {
|
||||
"message": "Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering."
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.title": {
|
||||
"message": "Advanced rendering"
|
||||
},
|
||||
"app.appearance-settings.color-theme.description": {
|
||||
"message": "Select your preferred color theme for Modrinth App."
|
||||
},
|
||||
"app.appearance-settings.color-theme.title": {
|
||||
"message": "Color theme"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.description": {
|
||||
"message": "Change the page to which the launcher opens on."
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.home": {
|
||||
"message": "Home"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.library": {
|
||||
"message": "Library"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.title": {
|
||||
"message": "Default landing page"
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.description": {
|
||||
"message": "Disables the nametag above your player on the skins page."
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.title": {
|
||||
"message": "Hide nametag"
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.description": {
|
||||
"message": "Includes recent worlds in the \"Jump back in\" section on the Home page."
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.title": {
|
||||
"message": "Jump back into worlds"
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.description": {
|
||||
"message": "Minimize the launcher when a Minecraft process starts."
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.title": {
|
||||
"message": "Minimize launcher"
|
||||
},
|
||||
"app.appearance-settings.native-decorations.description": {
|
||||
"message": "Use system window frame (app restart required)."
|
||||
},
|
||||
"app.appearance-settings.native-decorations.title": {
|
||||
"message": "Native decorations"
|
||||
},
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Select an option"
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.description": {
|
||||
"message": "Enables the ability to toggle the sidebar."
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.title": {
|
||||
"message": "Toggle sidebar"
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.description": {
|
||||
"message": "If you attempt to install a Modrinth Pack file (.mrpack) that isn't hosted on Modrinth, we'll make sure you understand the risks before installing it."
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.title": {
|
||||
"message": "Warn me before installing unknown modpacks"
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "Minecraft authentication servers may be down right now. Check your internet connection and try again later."
|
||||
},
|
||||
@@ -30,14 +129,32 @@
|
||||
"message": "Discover servers"
|
||||
},
|
||||
"app.browse.hide-added-servers": {
|
||||
"message": "Hide added servers"
|
||||
"message": "Hide already added servers"
|
||||
},
|
||||
"app.browse.hide-installed-content": {
|
||||
"message": "Hide installed content"
|
||||
"message": "Hide already installed content"
|
||||
},
|
||||
"app.browse.install-content-to-instance": {
|
||||
"message": "Install content to instance"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modpacks"
|
||||
},
|
||||
"app.browse.server.install": {
|
||||
"message": "Install"
|
||||
},
|
||||
"app.browse.server.installed": {
|
||||
"message": "Installed"
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Installing"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.description": {
|
||||
"message": "{fileName}"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.title": {
|
||||
"message": "Installing modpack..."
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Enter modpack description..."
|
||||
},
|
||||
@@ -598,5 +715,26 @@
|
||||
},
|
||||
"search.filter.locked.server-loader.title": {
|
||||
"message": "Loader is provided by the server"
|
||||
},
|
||||
"unknown-pack-warning-modal.body": {
|
||||
"message": "A file is only reviewed if it’s uploaded to Modrinth, regardless of its file format (including .mrpack)."
|
||||
},
|
||||
"unknown-pack-warning-modal.dont-show-again": {
|
||||
"message": "Don't show this warning again"
|
||||
},
|
||||
"unknown-pack-warning-modal.header": {
|
||||
"message": "Confirm installation"
|
||||
},
|
||||
"unknown-pack-warning-modal.install-anyway": {
|
||||
"message": "Install anyway"
|
||||
},
|
||||
"unknown-pack-warning-modal.malware-statement": {
|
||||
"message": "Malware is often distributed through modpack files by sharing them on platforms like Discord."
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.body": {
|
||||
"message": "We couldn't find this file on Modrinth. We strongly recommend only installing files from sources you trust."
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.title": {
|
||||
"message": "Unknown file warning"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +74,15 @@
|
||||
"app.instance.confirm-delete.header": {
|
||||
"message": "Tanggalin ang instansiya"
|
||||
},
|
||||
"app.instance.modpack-already-installed.body": {
|
||||
"message": "Naka-install na ang modpack sa instansiyang <bold>{instanceName}</bold>. Sigurado ka ba sa pag-ulit nito?"
|
||||
},
|
||||
"app.instance.modpack-already-installed.create": {
|
||||
"message": "Ilikha"
|
||||
},
|
||||
"app.instance.modpack-already-installed.header": {
|
||||
"message": "Naka-install na ang modpack"
|
||||
},
|
||||
"app.instance.modpack-already-installed.instance": {
|
||||
"message": "Instansiya"
|
||||
},
|
||||
@@ -89,6 +95,9 @@
|
||||
"app.instance.mods.projects-were-added": {
|
||||
"message": "{count} proyekto ang nadagdag"
|
||||
},
|
||||
"app.instance.mods.share-text": {
|
||||
"message": "Tingnan ang mga proyektong ginagamit ko sa aking modpack!"
|
||||
},
|
||||
"app.instance.mods.share-title": {
|
||||
"message": "Binabahagi ang kontento ng modpack"
|
||||
},
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"message": "Jette un coup d'œil aux projets que j'utilise dans mon modpack !"
|
||||
},
|
||||
"app.instance.mods.share-title": {
|
||||
"message": "Partage du contenu de votre modpack"
|
||||
"message": "Le contenu de mon modpack"
|
||||
},
|
||||
"app.instance.mods.successfully-uploaded": {
|
||||
"message": "Mis en ligne avec succès"
|
||||
|
||||
@@ -216,10 +216,10 @@
|
||||
"message": "Modrinth App v{version} 다운로드가 완료되었습니다. 새로고침하거나 Modrinth App을 종료하면 자동으로 업데이트됩니다."
|
||||
},
|
||||
"app.update-popup.body.linux": {
|
||||
"message": "Modrinth App v{version}을 사용할 수 있습니다. 최신 기능과 오류 수정을 적용하려면 패키지 관리자를 통해 업데이트하세요!"
|
||||
"message": "Modrinth 앱 v{version}을 사용할 수 있습니다. 최신 기능과 오류 수정을 적용하려면 패키지 관리자를 통해 업데이트하세요!"
|
||||
},
|
||||
"app.update-popup.body.metered": {
|
||||
"message": "Modrinth App v{version}을 지금 사용할 수 있습니다! 데이터 통신 연결을 사용 중이므로, 자동으로 다운로드하지 않았습니다."
|
||||
"message": "Modrinth 앱 v{version}을 지금 사용할 수 있습니다! 데이터 통신 연결을 사용 중이므로, 자동으로 다운로드하지 않았습니다."
|
||||
},
|
||||
"app.update-popup.changelog": {
|
||||
"message": "변경 내역"
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
"message": "Compartilhando conteúdo do pacote de mods\n\n\n \t\t\t\t\t\t\n \t\t\t\t\t\t\t\n \t\t\t\t\t\t\n \t\t\t\t\t\t"
|
||||
},
|
||||
"app.instance.mods.successfully-uploaded": {
|
||||
"message": "Enviado com sucesso"
|
||||
"message": "Adicionado com sucesso"
|
||||
},
|
||||
"app.instance.worlds.add-server": {
|
||||
"message": "Adicionar servidor"
|
||||
|
||||
@@ -261,7 +261,7 @@
|
||||
"message": "Несовместимая версия {version}"
|
||||
},
|
||||
"app.world.world-item.not-played-yet": {
|
||||
"message": "Ни разу не сыграно"
|
||||
"message": "Не запускалось"
|
||||
},
|
||||
"app.world.world-item.offline": {
|
||||
"message": "Не в сети"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"message": "Doğrulama sunucularına erişilemedi"
|
||||
},
|
||||
"app.browse.add-server-to-instance": {
|
||||
"message": "Sunucuyu kuruluma ekle"
|
||||
"message": "Kurulumunuza sunucu ekleyin"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Kurulumunuza sunucular ekleyin"
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
"message": "无法连接到身份验证服务器"
|
||||
},
|
||||
"app.browse.add-server-to-instance": {
|
||||
"message": "将服务器添加到实例"
|
||||
"message": "将服务器添加到实例实例"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "将服务器添加到你的实例"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "前往实例"
|
||||
"message": "添加到实例实例"
|
||||
},
|
||||
"app.browse.add-to-instance-name": {
|
||||
"message": "添加到 {instanceName}"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "已添加"
|
||||
},
|
||||
"app.browse.already-added": {
|
||||
"message": "已添加过"
|
||||
"message": "已添加"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "发现内容"
|
||||
@@ -33,7 +33,7 @@
|
||||
"message": "隐藏已添加的服务器"
|
||||
},
|
||||
"app.browse.hide-installed-content": {
|
||||
"message": "隐藏已安装的实例"
|
||||
"message": "隐藏已安装的内容"
|
||||
},
|
||||
"app.browse.install-content-to-instance": {
|
||||
"message": "将内容安装到实例中"
|
||||
@@ -75,7 +75,7 @@
|
||||
"message": "删除实例"
|
||||
},
|
||||
"app.instance.modpack-already-installed.body": {
|
||||
"message": "该整合包已经被安装到实例<bold>{instanceName}</bold> 中了。你确定要重复安装吗?"
|
||||
"message": "该整合包已经被安装到实例<bold>{instanceName}</bold>中了。你确定要重复安装吗?"
|
||||
},
|
||||
"app.instance.modpack-already-installed.create": {
|
||||
"message": "创建"
|
||||
@@ -96,7 +96,7 @@
|
||||
"message": "已添加 {count} 个项目"
|
||||
},
|
||||
"app.instance.mods.share-text": {
|
||||
"message": "查看在我整合包中使用的项目!"
|
||||
"message": "查看我在整合包中使用的项目!"
|
||||
},
|
||||
"app.instance.mods.share-title": {
|
||||
"message": "分享整合包内容"
|
||||
@@ -129,7 +129,7 @@
|
||||
"message": "原版"
|
||||
},
|
||||
"app.instance.worlds.no-worlds-description": {
|
||||
"message": "添加一个服务器或点击浏览来开始"
|
||||
"message": "通过添加一个服务器或点击浏览以开始"
|
||||
},
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "未添加服务器或世界"
|
||||
@@ -150,7 +150,7 @@
|
||||
"message": "此服务器"
|
||||
},
|
||||
"app.modal.install-to-play.content-required": {
|
||||
"message": "内容需求"
|
||||
"message": "需求内容"
|
||||
},
|
||||
"app.modal.install-to-play.header": {
|
||||
"message": "安装以游玩"
|
||||
@@ -162,7 +162,7 @@
|
||||
"message": "{count} 个模组"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "整合包需求"
|
||||
"message": "需求整合包"
|
||||
},
|
||||
"app.modal.install-to-play.server-requires-mods": {
|
||||
"message": "此服务器需要安装模组才能游玩。点击安装,从 Modrinth 下载所需文件,然后直接进入服务器。"
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
"message": "將伺服器新增到實例"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "新增伺服器到實例中"
|
||||
"message": "將伺服器新增到實例"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "新增至實例"
|
||||
},
|
||||
"app.browse.add-to-instance-name": {
|
||||
"message": "新增到「{instanceName}」"
|
||||
"message": "新增至「{instanceName}」"
|
||||
},
|
||||
"app.browse.added": {
|
||||
"message": "已新增"
|
||||
|
||||
@@ -34,6 +34,7 @@ import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import { get_project_v3, get_search_results_v3 } from '@/helpers/cache.js'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
|
||||
import { get_by_profile_path } from '@/helpers/process'
|
||||
import {
|
||||
get as getInstance,
|
||||
@@ -126,6 +127,8 @@ const instance: Ref<Instance | null> = ref(null)
|
||||
const installedProjectIds: Ref<string[] | null> = ref(null)
|
||||
const instanceHideInstalled = ref(false)
|
||||
const newlyInstalled = ref<string[]>([])
|
||||
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
|
||||
const hiddenInstanceProjectIdsInitialized = ref(false)
|
||||
const isServerInstance = ref(false)
|
||||
|
||||
if (isFromWorlds.value && route.params.projectType !== 'server') {
|
||||
@@ -141,6 +144,25 @@ const allInstalledIds = computed(
|
||||
() => new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])]),
|
||||
)
|
||||
|
||||
function syncHiddenInstanceProjectIds() {
|
||||
hiddenInstanceProjectIds.value = new Set([
|
||||
...(installedProjectIds.value ?? []),
|
||||
...newlyInstalled.value,
|
||||
])
|
||||
hiddenInstanceProjectIdsInitialized.value = true
|
||||
}
|
||||
|
||||
watch(
|
||||
installedProjectIds,
|
||||
(ids) => {
|
||||
if (!ids) return
|
||||
if (!hiddenInstanceProjectIdsInitialized.value) {
|
||||
syncHiddenInstanceProjectIds()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watchServerContextChanges()
|
||||
|
||||
await initInstanceContext()
|
||||
@@ -221,14 +243,10 @@ const instanceFilters = computed(() => {
|
||||
filters.push({ type: 'environment', option: 'client' })
|
||||
}
|
||||
|
||||
if (
|
||||
instanceHideInstalled.value &&
|
||||
(installedProjectIds.value || newlyInstalled.value.length > 0)
|
||||
) {
|
||||
const allInstalled = [...(installedProjectIds.value ?? []), ...newlyInstalled.value]
|
||||
allInstalled
|
||||
.map((x) => ({ type: 'project_id', option: `project_id:${x}`, negative: true }))
|
||||
.forEach((x) => filters.push(x))
|
||||
if (instanceHideInstalled.value && hiddenInstanceProjectIds.value.size > 0) {
|
||||
for (const id of hiddenInstanceProjectIds.value) {
|
||||
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +257,23 @@ const serverHideInstalled = ref(false)
|
||||
if (route.query.shi) {
|
||||
serverHideInstalled.value = route.query.shi === 'true'
|
||||
}
|
||||
const hiddenServerContentProjectIds = ref<Set<string>>(new Set())
|
||||
const hiddenServerContentProjectIdsInitialized = ref(false)
|
||||
|
||||
function syncHiddenServerContentProjectIds() {
|
||||
hiddenServerContentProjectIds.value = new Set(serverContentProjectIds.value)
|
||||
hiddenServerContentProjectIdsInitialized.value = true
|
||||
}
|
||||
|
||||
watch(
|
||||
serverContentProjectIds,
|
||||
() => {
|
||||
if (!hiddenServerContentProjectIdsInitialized.value) {
|
||||
syncHiddenServerContentProjectIds()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const serverContextFilters = computed(() => {
|
||||
const filters: { type: string; option: string; negative?: boolean }[] = []
|
||||
@@ -265,8 +300,8 @@ const serverContextFilters = computed(() => {
|
||||
)
|
||||
}
|
||||
|
||||
if (serverHideInstalled.value && serverContentProjectIds.value.size > 0) {
|
||||
for (const id of serverContentProjectIds.value) {
|
||||
if (serverHideInstalled.value && hiddenServerContentProjectIds.value.size > 0) {
|
||||
for (const id of hiddenServerContentProjectIds.value) {
|
||||
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
|
||||
}
|
||||
}
|
||||
@@ -279,6 +314,9 @@ const combinedProvidedFilters = computed(() =>
|
||||
)
|
||||
|
||||
const serverPings = shallowRef<Record<string, number | undefined>>({})
|
||||
const serverPingCache = new Map<string, number | undefined>()
|
||||
const pendingServerPings = new Map<string, Promise<number | undefined>>()
|
||||
let serverPingCacheActive = true
|
||||
const runningServerProjects = ref<Record<string, string>>({})
|
||||
|
||||
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
|
||||
@@ -341,16 +379,44 @@ async function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearc
|
||||
|
||||
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
|
||||
debugLog('pingServerHits', { hitCount: hits.length })
|
||||
const pingsToFetch = hits.filter((hit) => hit.minecraft_java_server?.address)
|
||||
const pingsToFetch = hits.flatMap((hit) => {
|
||||
const address = hit.minecraft_java_server?.address
|
||||
if (!address) return []
|
||||
return [{ hit, address }]
|
||||
})
|
||||
const nextPings = { ...serverPings.value }
|
||||
for (const { hit, address } of pingsToFetch) {
|
||||
if (serverPingCache.has(address)) {
|
||||
nextPings[hit.project_id] = serverPingCache.get(address)
|
||||
}
|
||||
}
|
||||
serverPings.value = nextPings
|
||||
|
||||
await Promise.all(
|
||||
pingsToFetch.map(async (hit) => {
|
||||
const address = hit.minecraft_java_server!.address!
|
||||
try {
|
||||
const latency = await getServerLatency(address)
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
|
||||
} catch (err) {
|
||||
console.error(`Failed to ping server ${address}:`, err)
|
||||
pingsToFetch.map(async ({ hit, address }) => {
|
||||
if (serverPingCache.has(address)) return
|
||||
|
||||
let pending = pendingServerPings.get(address)
|
||||
if (!pending) {
|
||||
pending = getServerLatency(address)
|
||||
.then((latency) => {
|
||||
if (serverPingCacheActive) serverPingCache.set(address, latency)
|
||||
return latency
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Failed to ping server ${address}:`, err)
|
||||
if (serverPingCacheActive) serverPingCache.set(address, undefined)
|
||||
return undefined
|
||||
})
|
||||
.finally(() => {
|
||||
pendingServerPings.delete(address)
|
||||
})
|
||||
pendingServerPings.set(address, pending)
|
||||
}
|
||||
|
||||
const latency = await pending
|
||||
if (!serverPingCacheActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -371,7 +437,10 @@ const unlistenProcesses = await process_listener(
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
serverPingCacheActive = false
|
||||
unlistenProcesses()
|
||||
serverPingCache.clear()
|
||||
pendingServerPings.clear()
|
||||
})
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
@@ -431,20 +500,36 @@ const messages = defineMessages({
|
||||
},
|
||||
hideAddedServers: {
|
||||
id: 'app.browse.hide-added-servers',
|
||||
defaultMessage: 'Hide added servers',
|
||||
defaultMessage: 'Hide already added servers',
|
||||
},
|
||||
hideInstalledContent: {
|
||||
id: 'app.browse.hide-installed-content',
|
||||
defaultMessage: 'Hide installed content',
|
||||
defaultMessage: 'Hide already installed content',
|
||||
},
|
||||
installContentToInstance: {
|
||||
id: 'app.browse.install-content-to-instance',
|
||||
defaultMessage: 'Install content to instance',
|
||||
},
|
||||
installToServer: {
|
||||
id: 'app.browse.server.install',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
installedToServer: {
|
||||
id: 'app.browse.server.installed',
|
||||
defaultMessage: 'Installed',
|
||||
},
|
||||
installingToServer: {
|
||||
id: 'app.browse.server.installing',
|
||||
defaultMessage: 'Installing',
|
||||
},
|
||||
modLoaderProvidedByInstance: {
|
||||
id: 'search.filter.locked.instance-loader.title',
|
||||
defaultMessage: 'Loader is provided by the instance',
|
||||
},
|
||||
modpacksProjectType: {
|
||||
id: 'app.browse.project-type.modpacks',
|
||||
defaultMessage: 'Modpacks',
|
||||
},
|
||||
modLoaderProvidedByServer: {
|
||||
id: 'search.filter.locked.server-loader.title',
|
||||
defaultMessage: 'Loader is provided by the server',
|
||||
@@ -550,7 +635,9 @@ const selectableProjectTypes = computed(() => {
|
||||
const suffix = queryString ? `?${queryString}` : ''
|
||||
|
||||
if (isSetupServerContext.value) {
|
||||
return [{ label: 'Modpacks', href: `/browse/modpack${suffix}` }]
|
||||
return [
|
||||
{ label: formatMessage(messages.modpacksProjectType), href: `/browse/modpack${suffix}` },
|
||||
]
|
||||
}
|
||||
|
||||
if (isFromWorlds.value) {
|
||||
@@ -644,6 +731,16 @@ const installContext = computed(() => {
|
||||
|
||||
const installingProjectIds = ref<Set<string>>(new Set())
|
||||
|
||||
function setProjectInstalling(projectId: string, installing: boolean) {
|
||||
const next = new Set(installingProjectIds.value)
|
||||
if (installing) {
|
||||
next.add(projectId)
|
||||
} else {
|
||||
next.delete(projectId)
|
||||
}
|
||||
installingProjectIds.value = next
|
||||
}
|
||||
|
||||
function getCardActions(
|
||||
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
||||
currentProjectType: string,
|
||||
@@ -730,13 +827,20 @@ function getCardActions(
|
||||
return [
|
||||
{
|
||||
key: 'install',
|
||||
label: isInstalling ? 'Installing' : isInstalled ? 'Installed' : 'Install',
|
||||
label: formatMessage(
|
||||
isInstalling
|
||||
? messages.installingToServer
|
||||
: isInstalled
|
||||
? messages.installedToServer
|
||||
: messages.installToServer,
|
||||
),
|
||||
icon: isInstalled ? CheckIcon : PlusIcon,
|
||||
iconClass: isInstalling ? 'animate-spin' : undefined,
|
||||
disabled: isInstalled || isInstalling,
|
||||
color: 'brand',
|
||||
type: 'outlined',
|
||||
onClick: async () => {
|
||||
installingProjectIds.value.add(projectResult.project_id)
|
||||
setProjectInstalling(projectResult.project_id, true)
|
||||
try {
|
||||
const didInstall = await installProjectToServer(projectResult)
|
||||
if (didInstall !== false) {
|
||||
@@ -745,7 +849,7 @@ function getCardActions(
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
installingProjectIds.value.delete(projectResult.project_id)
|
||||
setProjectInstalling(projectResult.project_id, false)
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -766,18 +870,19 @@ function getCardActions(
|
||||
? 'Install'
|
||||
: 'Add to an instance',
|
||||
icon: isInstalling ? SpinnerIcon : isInstalled ? CheckIcon : PlusIcon,
|
||||
iconClass: isInstalling ? 'animate-spin' : undefined,
|
||||
disabled: isInstalled || isInstalling,
|
||||
color: 'brand',
|
||||
type: 'outlined',
|
||||
onClick: async () => {
|
||||
installingProjectIds.value.add(projectResult.project_id)
|
||||
setProjectInstalling(projectResult.project_id, true)
|
||||
await installVersion(
|
||||
projectResult.project_id,
|
||||
null,
|
||||
instance.value ? instance.value.path : null,
|
||||
'SearchCard',
|
||||
(versionId) => {
|
||||
installingProjectIds.value.delete(projectResult.project_id)
|
||||
setProjectInstalling(projectResult.project_id, false)
|
||||
if (versionId) {
|
||||
onSearchResultInstalled(projectResult.project_id)
|
||||
}
|
||||
@@ -790,7 +895,7 @@ function getCardActions(
|
||||
preferredGameVersion: instance.value?.game_version ?? undefined,
|
||||
},
|
||||
).catch((err) => {
|
||||
installingProjectIds.value.delete(projectResult.project_id)
|
||||
setProjectInstalling(projectResult.project_id, false)
|
||||
handleError(err)
|
||||
})
|
||||
},
|
||||
@@ -833,7 +938,6 @@ async function search(requestParams: string) {
|
||||
if (isServer) {
|
||||
const hits = rawResults.result.hits ?? []
|
||||
lastServerHits.value = hits
|
||||
serverPings.value = {}
|
||||
pingServerHits(hits)
|
||||
checkServerRunningStates(hits)
|
||||
return {
|
||||
@@ -903,6 +1007,23 @@ const searchState = useBrowseSearch({
|
||||
}),
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
() => searchState.query.value,
|
||||
() => searchState.currentFilters.value,
|
||||
() => searchState.serverCurrentFilters.value,
|
||||
() => projectType.value,
|
||||
],
|
||||
() => {
|
||||
if (isServerContext.value) {
|
||||
syncHiddenServerContentProjectIds()
|
||||
} else if (instance.value) {
|
||||
syncHiddenInstanceProjectIds()
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
if (instance.value?.game_version) {
|
||||
const gv = instance.value.game_version
|
||||
const alreadyHasGv = searchState.serverCurrentFilters.value.some(
|
||||
@@ -934,8 +1055,13 @@ provideBrowseManager({
|
||||
hideInstalled: computed({
|
||||
get: () => (isServerContext.value ? serverHideInstalled.value : instanceHideInstalled.value),
|
||||
set: (val: boolean) => {
|
||||
if (isServerContext.value) serverHideInstalled.value = val
|
||||
else instanceHideInstalled.value = val
|
||||
if (isServerContext.value) {
|
||||
serverHideInstalled.value = val
|
||||
if (val) syncHiddenServerContentProjectIds()
|
||||
} else {
|
||||
instanceHideInstalled.value = val
|
||||
if (val) syncHiddenInstanceProjectIds()
|
||||
}
|
||||
},
|
||||
}),
|
||||
showHideInstalled: computed(
|
||||
@@ -972,6 +1098,7 @@ provideBrowseManager({
|
||||
:on-back="onServerFlowBack"
|
||||
:search-modpacks="searchServerModpacks"
|
||||
:get-project-versions="getServerProjectVersions"
|
||||
:get-loader-manifest="getLoaderManifest"
|
||||
@hide="() => {}"
|
||||
@browse-modpacks="() => {}"
|
||||
@create="handleServerModpackFlowCreate"
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
:resolve-viewer="resolveViewer"
|
||||
:show-copy-id-action="themeStore.devMode"
|
||||
:auth-user="authUser"
|
||||
:fetch-intercom-token="fetchIntercomToken"
|
||||
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
|
||||
:navigate-to-servers="() => router.push('/hosting/manage')"
|
||||
:browse-modpacks="
|
||||
@@ -47,12 +46,10 @@
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { config } from '@/config'
|
||||
import { get_user } from '@/helpers/cache'
|
||||
import { get as getCreds } from '@/helpers/mr_auth'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
@@ -123,26 +120,6 @@ const authUser = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchIntercomToken(): Promise<{ token: string }> {
|
||||
const credentials = await getCreds()
|
||||
if (!credentials?.session) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
const response = await tauriFetch(
|
||||
`${config.siteUrl}/api/intercom/messenger-jwt?server_id=${encodeURIComponent(serverId.value)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${credentials.session}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Intercom token: ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as { token: string }
|
||||
}
|
||||
|
||||
async function resolveViewer(): Promise<{ userId: string | null; userRole: string | null }> {
|
||||
const credentials = await getCreds().catch(() => null)
|
||||
if (!credentials?.user_id) {
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import type { AbstractWebNotificationManager } from '@modrinth/ui'
|
||||
import type { AbstractPopupNotificationManager, AbstractWebNotificationManager } from '@modrinth/ui'
|
||||
|
||||
import { setupCreationModal } from './setup/creation-modal'
|
||||
import { setupFilePickerProvider } from './setup/file-picker'
|
||||
import { setupInstanceImportProvider } from './setup/instance-import'
|
||||
import { setupTagsProvider } from './setup/tags'
|
||||
|
||||
export function setupProviders(notificationManager: AbstractWebNotificationManager) {
|
||||
export function setupProviders(
|
||||
notificationManager: AbstractWebNotificationManager,
|
||||
popupNotificationManager: AbstractPopupNotificationManager,
|
||||
) {
|
||||
setupTagsProvider(notificationManager)
|
||||
setupFilePickerProvider()
|
||||
setupInstanceImportProvider(notificationManager)
|
||||
|
||||
return {
|
||||
...setupCreationModal(notificationManager),
|
||||
...setupCreationModal(notificationManager, popupNotificationManager),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,47 @@
|
||||
import type {
|
||||
AbstractPopupNotificationManager,
|
||||
AbstractWebNotificationManager,
|
||||
CreationFlowContextValue,
|
||||
CreationFlowModal,
|
||||
} from '@modrinth/ui'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { provide, ref, useTemplateRef } from 'vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import type UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
|
||||
import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_versions, get_search_results } from '@/helpers/cache.js'
|
||||
import { import_instance } from '@/helpers/import.js'
|
||||
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata.js'
|
||||
import { create_profile_and_install, create_profile_and_install_from_file } from '@/helpers/pack'
|
||||
import { create, list } from '@/helpers/profile.js'
|
||||
import type { InstanceLoader } from '@/helpers/types'
|
||||
|
||||
export function setupCreationModal(notificationManager: AbstractWebNotificationManager) {
|
||||
export function setupCreationModal(
|
||||
notificationManager: AbstractWebNotificationManager,
|
||||
popupNotificationManager: AbstractPopupNotificationManager,
|
||||
) {
|
||||
const { handleError } = notificationManager
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
|
||||
const messages = defineMessages({
|
||||
installingModpackTitle: {
|
||||
id: 'app.creation-modal.installing-modpack.title',
|
||||
defaultMessage: 'Installing modpack...',
|
||||
},
|
||||
installingModpackDescription: {
|
||||
id: 'app.creation-modal.installing-modpack.description',
|
||||
defaultMessage: '{fileName}',
|
||||
},
|
||||
})
|
||||
|
||||
const installationModal =
|
||||
useTemplateRef<ComponentExposed<typeof CreationFlowModal>>('installationModal')
|
||||
const unknownPackWarningModal =
|
||||
useTemplateRef<InstanceType<typeof UnknownPackWarningModal>>('unknownPackWarningModal')
|
||||
const modpackAlreadyInstalledModal = ref<InstanceType<typeof ModpackAlreadyInstalledModal>>()
|
||||
|
||||
function setModpackAlreadyInstalledModal(
|
||||
@@ -87,7 +108,24 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
|
||||
}
|
||||
|
||||
if (config.modpackFilePath.value) {
|
||||
await create_profile_and_install_from_file(config.modpackFilePath.value).catch(handleError)
|
||||
const waitingNotification = popupNotificationManager.addPopupNotification({
|
||||
title: formatMessage(messages.installingModpackTitle),
|
||||
text: formatMessage(messages.installingModpackDescription, {
|
||||
fileName: config.modpackFilePath.value.split('/').pop() ?? config.modpackFilePath.value,
|
||||
}),
|
||||
type: 'info',
|
||||
autoCloseMs: null,
|
||||
waiting: true,
|
||||
})
|
||||
|
||||
await create_profile_and_install_from_file(
|
||||
config.modpackFilePath.value,
|
||||
(createProfile, fileName) => {
|
||||
popupNotificationManager.removeNotification(waitingNotification.id)
|
||||
unknownPackWarningModal.value?.show(createProfile, fileName)
|
||||
},
|
||||
).catch(handleError)
|
||||
popupNotificationManager.removeNotification(waitingNotification.id)
|
||||
trackEvent('InstanceCreate', { source: 'CreationModalModpackFile' })
|
||||
return
|
||||
}
|
||||
@@ -160,11 +198,13 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
|
||||
|
||||
return {
|
||||
installationModal,
|
||||
unknownPackWarningModal,
|
||||
fetchExistingInstanceNames,
|
||||
handleCreate,
|
||||
handleBrowseModpacks,
|
||||
searchModpacks,
|
||||
getProjectVersions,
|
||||
getLoaderManifest,
|
||||
setModpackAlreadyInstalledModal,
|
||||
handleModpackDuplicateCreateAnyway,
|
||||
handleModpackDuplicateGoToInstance,
|
||||
|
||||
@@ -240,7 +240,7 @@ export default new createRouter({
|
||||
component: Instance.Logs,
|
||||
meta: {
|
||||
useRootContext: true,
|
||||
renderMode: 'fixed',
|
||||
// renderMode: 'fixed',
|
||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Logs' }],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,6 +7,8 @@ export const DEFAULT_FEATURE_FLAGS = {
|
||||
worlds_in_home: true,
|
||||
server_project_qa: false,
|
||||
server_ram_as_bytes_always_on: false,
|
||||
always_show_app_controls: false,
|
||||
skip_unknown_pack_warning: false,
|
||||
i18n_debug: false,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"identifier": "plugins",
|
||||
"description": "",
|
||||
"local": true,
|
||||
"windows": ["main"],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-confirm",
|
||||
@@ -19,21 +21,36 @@
|
||||
"window-state:default",
|
||||
"window-state:allow-restore-state",
|
||||
"window-state:allow-save-window-state",
|
||||
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "https://modrinth.com/*" },
|
||||
{ "url": "https://*.modrinth.com/*" },
|
||||
{ "url": "https://*.nodes.modrinth.com/*" },
|
||||
{ "url": "https://api.mclo.gs/*" },
|
||||
{ "url": "https://fill.papermc.io/*" },
|
||||
{ "url": "https://api.purpurmc.org/*" }
|
||||
{
|
||||
"url": "https://modrinth.com/*"
|
||||
},
|
||||
{
|
||||
"url": "https://*.modrinth.com/*"
|
||||
},
|
||||
{
|
||||
"url": "https://*.nodes.modrinth.com/*"
|
||||
},
|
||||
{
|
||||
"url": "https://api.mclo.gs/*"
|
||||
},
|
||||
{
|
||||
"url": "https://fill.papermc.io/*"
|
||||
},
|
||||
{
|
||||
"url": "https://api.purpurmc.org/*"
|
||||
},
|
||||
{
|
||||
"url": "http://*.taila228c5.ts.net/*"
|
||||
},
|
||||
{
|
||||
"url": "https://*.taila228c5.ts.net/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"dialog:allow-save",
|
||||
|
||||
"fs:allow-read-dir",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-read-text-file",
|
||||
@@ -49,15 +66,26 @@
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [
|
||||
{ "path": "$APPDATA/profiles" },
|
||||
{ "path": "$APPDATA/profiles/**" },
|
||||
{ "path": "$APPCONFIG/profiles" },
|
||||
{ "path": "$APPCONFIG/profiles/**" },
|
||||
{ "path": "$CONFIG/profiles" },
|
||||
{ "path": "$CONFIG/profiles/**" }
|
||||
{
|
||||
"path": "$APPDATA/profiles"
|
||||
},
|
||||
{
|
||||
"path": "$APPDATA/profiles/**"
|
||||
},
|
||||
{
|
||||
"path": "$APPCONFIG/profiles"
|
||||
},
|
||||
{
|
||||
"path": "$APPCONFIG/profiles/**"
|
||||
},
|
||||
{
|
||||
"path": "$CONFIG/profiles"
|
||||
},
|
||||
{
|
||||
"path": "$CONFIG/profiles/**"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"auth:default",
|
||||
"import:default",
|
||||
"jre:default",
|
||||
|
||||
@@ -26,8 +26,8 @@ pub async fn pack_install(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pack_get_profile_from_pack(
|
||||
pub async fn pack_get_profile_from_pack(
|
||||
location: CreatePackLocation,
|
||||
) -> Result<CreatePackProfile> {
|
||||
Ok(pack::install_from::get_profile_from_pack(location))
|
||||
Ok(pack::install_from::get_profile_from_pack(location).await?)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,12 @@
|
||||
"copyright": "",
|
||||
"targets": "all",
|
||||
"externalBin": [],
|
||||
"icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
|
||||
"icon": [
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
@@ -35,7 +40,9 @@
|
||||
},
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": ["mrpack"],
|
||||
"ext": [
|
||||
"mrpack"
|
||||
],
|
||||
"mimeType": "application/x-modrinth-modpack+zip"
|
||||
}
|
||||
]
|
||||
@@ -47,7 +54,9 @@
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["modrinth"]
|
||||
"schemes": [
|
||||
"modrinth"
|
||||
]
|
||||
},
|
||||
"mobile": []
|
||||
}
|
||||
@@ -84,15 +93,22 @@
|
||||
],
|
||||
"enable": true
|
||||
},
|
||||
"capabilities": ["ads", "core", "plugins"],
|
||||
"capabilities": [
|
||||
"ads",
|
||||
"core",
|
||||
"plugins"
|
||||
],
|
||||
"csp": {
|
||||
"default-src": "'self' customprotocol: asset:",
|
||||
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com wss://*.nodes.modrinth.com wss://*.ts.net https://fill.papermc.io https://api.purpurmc.org 'self' data: blob:",
|
||||
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
||||
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https://www.intercom-reporting.com https://app.getsentry.com wss://*.nodes.modrinth.com https://*.taila228c5.ts.net https://*.taila228c5.ts.net wss://*.taila228c5.ts.net https://fill.papermc.io https://api.purpurmc.org 'self' data: blob:",
|
||||
"font-src": [
|
||||
"https://cdn-raw.modrinth.com/fonts/",
|
||||
"https://js.intercomcdn.com"
|
||||
],
|
||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
||||
"style-src": "'unsafe-inline' 'self'",
|
||||
"script-src": "https://*.posthog.com https://posthog.modrinth.com https://js.stripe.com https://tally.so/widgets/embed.js 'self'",
|
||||
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ https://js.stripe.com https://hooks.stripe.com 'self'",
|
||||
"script-src": "https://*.posthog.com https://posthog.modrinth.com https://js.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://tally.so/widgets/embed.js 'self'",
|
||||
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ https://js.stripe.com https://hooks.stripe.com https://*.intercom.io https://intercom-sheets.com https://www.intercom-reporting.com https://app.intercom.com 'self'",
|
||||
"media-src": "https://*.githubusercontent.com"
|
||||
}
|
||||
}
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
CLAUDE.md
|
||||
@@ -258,7 +258,6 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
modules: [
|
||||
'@pinia/nuxt',
|
||||
'floating-vue/nuxt',
|
||||
// Sentry causes rollup-plugin-inject errors in dev, only enable in production
|
||||
...(isProduction() ? ['@sentry/nuxt/module'] : []),
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"@modrinth/moderation": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@sentry/nuxt": "^10.33.0",
|
||||
"@tanstack/vue-query": "^5.90.7",
|
||||
"@types/three": "^0.172.0",
|
||||
@@ -69,8 +68,6 @@
|
||||
"lru-cache": "^11.2.4",
|
||||
"markdown-it": "14.1.0",
|
||||
"pathe": "^1.1.2",
|
||||
"pinia": "^3.0.0",
|
||||
"pinia-plugin-persistedstate": "^4.4.1",
|
||||
"prettier": "^3.6.2",
|
||||
"qrcode.vue": "^3.4.0",
|
||||
"semver": "^7.5.4",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import '@modrinth/assets/styles/reset.scss';
|
||||
|
||||
html {
|
||||
--dark-color-text: #b0bac5;
|
||||
--dark-color-text-dark: #ecf9fb;
|
||||
|
||||
@@ -273,7 +273,7 @@ async function closeReport(reply = false) {
|
||||
closed: true,
|
||||
},
|
||||
})
|
||||
updateThread(props.report.thread)
|
||||
await refreshReportCaches()
|
||||
didCloseReport.value = true
|
||||
} catch (err: any) {
|
||||
addNotification({
|
||||
@@ -292,7 +292,7 @@ async function reopenReport() {
|
||||
closed: false,
|
||||
},
|
||||
})
|
||||
updateThread(props.report.thread)
|
||||
await refreshReportCaches()
|
||||
didCloseReport.value = false
|
||||
} catch (err: any) {
|
||||
addNotification({
|
||||
@@ -309,6 +309,18 @@ const formatDateTime = useFormatDateTime({
|
||||
dateStyle: 'long',
|
||||
})
|
||||
|
||||
async function refreshReportCaches() {
|
||||
await Promise.allSettled([refreshThread(), refreshNuxtData('new-moderation-reports')])
|
||||
}
|
||||
|
||||
async function refreshThread() {
|
||||
const threadId = props.report.thread?.id ?? props.report.thread_id
|
||||
if (!threadId) return
|
||||
|
||||
const thread = await useBaseFetch(`thread/${threadId}`)
|
||||
updateThread(thread)
|
||||
}
|
||||
|
||||
function updateThread(newThread: any) {
|
||||
if (props.report.thread) {
|
||||
Object.assign(props.report.thread, newThread)
|
||||
|
||||
@@ -89,7 +89,7 @@ const { addNotification } = injectNotificationManager()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refetch: []
|
||||
loadFileSources: [reportId: string]
|
||||
loadIssueSources: [issueIds: string[]]
|
||||
markComplete: [projectId: string]
|
||||
showMaliciousSummary: [unsafeFiles: UnsafeFile[]]
|
||||
}>()
|
||||
@@ -182,9 +182,55 @@ async function updateIssueDetails(data: { detail_id: string; verdict: 'safe' | '
|
||||
|
||||
const severityOrder = { severe: 3, high: 2, medium: 1, low: 0 } as Record<string, number>
|
||||
|
||||
const detailDecisions = reactive<Map<string, 'safe' | 'malware'>>(new Map())
|
||||
type DetailDecision = 'safe' | 'malware'
|
||||
|
||||
const detailDecisions = reactive<Map<string, DetailDecision>>(new Map())
|
||||
const updatingDetails = reactive<Set<string>>(new Set())
|
||||
|
||||
function verdictToDecision(verdict: 'safe' | 'unsafe'): DetailDecision {
|
||||
return verdict === 'safe' ? 'safe' : 'malware'
|
||||
}
|
||||
|
||||
function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
|
||||
return props.item.reports.flatMap((report) => report.issues.flatMap((issue) => issue.details))
|
||||
}
|
||||
|
||||
function applyDecisionToRelatedDetails(
|
||||
detailIds: string[],
|
||||
decision: DetailDecision,
|
||||
): { otherMatchedCount: number } {
|
||||
const allDetails = getAllDetails()
|
||||
const selectedDetailIds = new Set(detailIds)
|
||||
const updatedDetailIds = new Set<string>()
|
||||
|
||||
for (const detailId of detailIds) {
|
||||
const detail = allDetails.find((candidate) => candidate.id === detailId)
|
||||
let matchingDetails: Labrinth.TechReview.Internal.ReportIssueDetail[] = []
|
||||
|
||||
if (detail?.key) {
|
||||
matchingDetails = allDetails.filter((candidate) => candidate.key === detail.key)
|
||||
} else if (detail) {
|
||||
matchingDetails = [detail]
|
||||
}
|
||||
|
||||
if (matchingDetails.length === 0) {
|
||||
detailDecisions.set(detailId, decision)
|
||||
updatedDetailIds.add(detailId)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const matchingDetail of matchingDetails) {
|
||||
detailDecisions.set(matchingDetail.id, decision)
|
||||
updatedDetailIds.add(matchingDetail.id)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
otherMatchedCount: [...updatedDetailIds].filter((detailId) => !selectedDetailIds.has(detailId))
|
||||
.length,
|
||||
}
|
||||
}
|
||||
|
||||
function getFileHighestSeverity(
|
||||
file: FlattenedFileReport,
|
||||
): Labrinth.TechReview.Internal.DelphiSeverity {
|
||||
@@ -325,7 +371,6 @@ function formatFileSize(bytes: number): string {
|
||||
function viewFileFlags(file: FlattenedFileReport) {
|
||||
selectedFileId.value = file.id
|
||||
currentTab.value = 'File'
|
||||
emit('loadFileSources', file.id)
|
||||
}
|
||||
|
||||
function backToFileList() {
|
||||
@@ -416,10 +461,7 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
||||
try {
|
||||
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
|
||||
|
||||
const decision = verdict === 'safe' ? 'safe' : 'malware'
|
||||
for (const detailId of detailIds) {
|
||||
detailDecisions.set(detailId, decision)
|
||||
}
|
||||
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict))
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
@@ -435,6 +477,8 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
||||
backToFileList()
|
||||
}
|
||||
}
|
||||
|
||||
emit('refetch')
|
||||
} catch (error) {
|
||||
console.error('Failed to batch update:', error)
|
||||
addNotification({
|
||||
@@ -464,37 +508,10 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
||||
try {
|
||||
await updateIssueDetails([{ detail_id: detailId, verdict }])
|
||||
|
||||
const decision = verdict === 'safe' ? 'safe' : 'malware'
|
||||
|
||||
let detailKey: string | null = null
|
||||
for (const report of props.item.reports) {
|
||||
for (const issue of report.issues) {
|
||||
const detail = issue.details.find((d) => d.id === detailId)
|
||||
if (detail) {
|
||||
detailKey = detail.key
|
||||
break
|
||||
}
|
||||
}
|
||||
if (detailKey) break
|
||||
}
|
||||
|
||||
let otherMatchedCount = 0
|
||||
if (detailKey) {
|
||||
for (const report of props.item.reports) {
|
||||
for (const issue of report.issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (detail.key === detailKey) {
|
||||
detailDecisions.set(detail.id, decision)
|
||||
if (detail.id !== detailId) {
|
||||
otherMatchedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
detailDecisions.set(detailId, decision)
|
||||
}
|
||||
const { otherMatchedCount } = applyDecisionToRelatedDetails(
|
||||
[detailId],
|
||||
verdictToDecision(verdict),
|
||||
)
|
||||
|
||||
// Only collapse if the prior state was 'pending' (new decision, not updating existing)
|
||||
if (priorDecision === 'pending') {
|
||||
@@ -534,6 +551,8 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
||||
text: `This issue has been flagged as malicious.${otherText}`,
|
||||
})
|
||||
}
|
||||
|
||||
emit('refetch')
|
||||
} catch (error) {
|
||||
console.error('Failed to update detail status:', error)
|
||||
addNotification({
|
||||
@@ -547,7 +566,10 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
||||
}
|
||||
|
||||
const expandedClasses = reactive<Set<string>>(new Set())
|
||||
const autoExpandedFileIds = reactive<Set<string>>(new Set())
|
||||
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
|
||||
const highlightedSourceCache = reactive<Map<string, { source: string; lines: string[] }>>(new Map())
|
||||
const LAZY_LOAD_CLASS_SOURCE_MINIMUM = 10
|
||||
|
||||
interface ClassGroup {
|
||||
key: string
|
||||
@@ -582,6 +604,10 @@ function splitJarSegments(jar: string | null, currentFileName: string | null): s
|
||||
return segments
|
||||
}
|
||||
|
||||
function isRootJarGroup(jarGroup: JarGroup): boolean {
|
||||
return jarGroup.segments.length === 0
|
||||
}
|
||||
|
||||
const groupedByClass = computed<ClassGroup[]>(() => {
|
||||
if (!selectedFile.value) return []
|
||||
|
||||
@@ -647,18 +673,28 @@ const groupedByJar = computed<JarGroup[]>(() => {
|
||||
}
|
||||
|
||||
return Array.from(jarMap.values()).sort((a, b) => {
|
||||
const aRoot = isRootJarGroup(a)
|
||||
const bRoot = isRootJarGroup(b)
|
||||
if (aRoot !== bRoot) return aRoot ? -1 : 1
|
||||
|
||||
const aSeverity = getHighestSeverityInClass(a.classes.flatMap((classItem) => classItem.flags))
|
||||
const bSeverity = getHighestSeverityInClass(b.classes.flatMap((classItem) => classItem.flags))
|
||||
return (severityOrder[bSeverity] ?? 0) - (severityOrder[aSeverity] ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
// Auto-expand if there's only one class in the file
|
||||
// Auto-expand/load source for small files; keep larger files lazy.
|
||||
watch(
|
||||
groupedByClass,
|
||||
(classes) => {
|
||||
if (classes.length === 1) {
|
||||
expandedClasses.add(classes[0].key)
|
||||
[selectedFileId, groupedByClass],
|
||||
([fileId, classes]) => {
|
||||
if (!fileId || classes.length === 0 || autoExpandedFileIds.has(fileId)) return
|
||||
|
||||
autoExpandedFileIds.add(fileId)
|
||||
|
||||
if (classes.length < LAZY_LOAD_CLASS_SOURCE_MINIMUM) {
|
||||
for (const classItem of classes) {
|
||||
expandClass(classItem)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -676,14 +712,6 @@ function getHighestSeverityInClass(
|
||||
)
|
||||
}
|
||||
|
||||
function toggleClass(classKey: string) {
|
||||
if (expandedClasses.has(classKey)) {
|
||||
expandedClasses.delete(classKey)
|
||||
} else {
|
||||
expandedClasses.add(classKey)
|
||||
}
|
||||
}
|
||||
|
||||
function getClassDecompiledSource(classItem: ClassGroup): string | undefined {
|
||||
for (const flag of classItem.flags) {
|
||||
const source = props.decompiledSources.get(flag.detail.id)
|
||||
@@ -692,6 +720,43 @@ function getClassDecompiledSource(classItem: ClassGroup): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getHighlightedClassSource(classItem: ClassGroup): string[] {
|
||||
const source = getClassDecompiledSource(classItem)
|
||||
if (!source) return []
|
||||
|
||||
const cached = highlightedSourceCache.get(classItem.key)
|
||||
if (cached?.source === source) return cached.lines
|
||||
|
||||
const lines = highlightCodeLines(source, 'java')
|
||||
highlightedSourceCache.set(classItem.key, { source, lines })
|
||||
return lines
|
||||
}
|
||||
|
||||
function isClassLoadingSource(classItem: ClassGroup): boolean {
|
||||
return classItem.flags.some((flag) => props.loadingIssues.has(flag.issueId))
|
||||
}
|
||||
|
||||
function loadClassSources(classItem: ClassGroup) {
|
||||
const issueIds = [...new Set(classItem.flags.map((flag) => flag.issueId))]
|
||||
if (issueIds.length > 0) {
|
||||
emit('loadIssueSources', issueIds)
|
||||
}
|
||||
}
|
||||
|
||||
function expandClass(classItem: ClassGroup) {
|
||||
if (expandedClasses.has(classItem.key)) return
|
||||
expandedClasses.add(classItem.key)
|
||||
loadClassSources(classItem)
|
||||
}
|
||||
|
||||
function toggleClass(classItem: ClassGroup) {
|
||||
if (expandedClasses.has(classItem.key)) {
|
||||
expandedClasses.delete(classItem.key)
|
||||
} else {
|
||||
expandClass(classItem)
|
||||
}
|
||||
}
|
||||
|
||||
function handleThreadUpdate() {
|
||||
emit('refetch')
|
||||
}
|
||||
@@ -1203,7 +1268,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between p-4 transition-colors duration-200 hover:bg-surface-4"
|
||||
@click="toggleClass(classItem.key)"
|
||||
@click="toggleClass(classItem)"
|
||||
>
|
||||
<div class="my-auto flex items-center gap-2">
|
||||
<ButtonStyled type="transparent" circular>
|
||||
@@ -1245,7 +1310,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="classItem.flags.some((f) => loadingIssues.has(f.issueId))"
|
||||
v-if="isClassLoadingSource(classItem)"
|
||||
class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1"
|
||||
>
|
||||
<span class="flex items-center gap-1.5 text-sm font-medium text-secondary">
|
||||
@@ -1258,7 +1323,10 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
</div>
|
||||
|
||||
<Collapsible :collapsed="!expandedClasses.has(classItem.key)">
|
||||
<div class="mt-2 flex flex-col gap-2 px-4 pb-4">
|
||||
<div
|
||||
v-if="expandedClasses.has(classItem.key)"
|
||||
class="mt-2 flex flex-col gap-2 px-4 pb-4"
|
||||
>
|
||||
<div
|
||||
v-for="flag in classItem.flags"
|
||||
:key="`${flag.issueId}-${flag.detail.id}`"
|
||||
@@ -1347,7 +1415,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="getClassDecompiledSource(classItem)"
|
||||
v-if="getHighlightedClassSource(classItem).length > 0"
|
||||
class="relative inset-0 overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-4"
|
||||
>
|
||||
<ButtonStyled circular type="transparent">
|
||||
@@ -1363,10 +1431,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
|
||||
<div class="overflow-x-auto bg-surface-3 py-3">
|
||||
<div
|
||||
v-for="(line, n) in highlightCodeLines(
|
||||
getClassDecompiledSource(classItem)!,
|
||||
'java',
|
||||
)"
|
||||
v-for="(line, n) in getHighlightedClassSource(classItem)"
|
||||
:key="n"
|
||||
class="flex font-mono text-[13px] leading-[1.6]"
|
||||
>
|
||||
@@ -1382,6 +1447,15 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isClassLoadingSource(classItem)"
|
||||
class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4"
|
||||
>
|
||||
<p class="flex items-center gap-2 text-sm text-secondary">
|
||||
<LoaderCircleIcon class="size-4 animate-spin" />
|
||||
Loading source...
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4"
|
||||
|
||||
@@ -79,13 +79,13 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled
|
||||
v-if="moderationStore.isQueueMode && moderationStore.queueLength > 1"
|
||||
v-if="moderationQueue.isQueueMode && moderationQueue.queueLength > 1"
|
||||
color="brand"
|
||||
@click="skipToNextProject"
|
||||
>
|
||||
<button>
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
Next project ({{ moderationStore.queueLength }} left)
|
||||
Next project ({{ moderationQueue.queueLength }} left)
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -112,13 +112,13 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled
|
||||
v-if="moderationStore.isQueueMode && moderationStore.queueLength > 1"
|
||||
v-if="moderationQueue.isQueueMode && moderationQueue.queueLength > 1"
|
||||
color="brand"
|
||||
@click="skipToNextProject"
|
||||
>
|
||||
<button>
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
Next project ({{ moderationStore.queueLength }} left)
|
||||
Next project ({{ moderationQueue.queueLength }} left)
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -131,9 +131,9 @@
|
||||
<div v-if="done">
|
||||
<p>
|
||||
You are done moderating this project!
|
||||
<template v-if="moderationStore.hasItems">
|
||||
<template v-if="moderationQueue.hasItems">
|
||||
There are
|
||||
{{ moderationStore.queueLength }} left.
|
||||
{{ moderationQueue.queueLength }} left.
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
@@ -159,6 +159,7 @@
|
||||
:disabled="false"
|
||||
:heading-buttons="false"
|
||||
:on-image-upload="onUploadHandler"
|
||||
@input="persistGeneratedMessageState"
|
||||
/>
|
||||
<StyledInput
|
||||
v-else
|
||||
@@ -167,7 +168,7 @@
|
||||
placeholder="No message generated."
|
||||
autocomplete="off"
|
||||
input-class="h-[400px] font-mono"
|
||||
@input="persistState"
|
||||
@input="persistGeneratedMessageState"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,10 +332,10 @@
|
||||
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">
|
||||
<ButtonStyled v-if="!done && !generatedMessage && moderationQueue.hasItems">
|
||||
<button @click="skipCurrentProject">
|
||||
<XIcon aria-hidden="true" />
|
||||
Skip ({{ moderationStore.queueLength }} left)
|
||||
Skip ({{ moderationQueue.queueLength }} left)
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -345,7 +346,7 @@
|
||||
<button @click="endChecklist(undefined)">
|
||||
<template v-if="hasNextProject">
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
Next project ({{ moderationStore.queueLength }} left)
|
||||
Next project ({{ moderationQueue.queueLength }} left)
|
||||
</template>
|
||||
<template v-else>
|
||||
<CheckIcon aria-hidden="true" />
|
||||
@@ -441,10 +442,10 @@ import {
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
type Action,
|
||||
type ActionState,
|
||||
type ButtonAction,
|
||||
checklist,
|
||||
type ConditionalButtonAction,
|
||||
deserializeActionStates,
|
||||
type DropdownAction,
|
||||
expandVariables,
|
||||
finalPermissionMessages,
|
||||
@@ -461,7 +462,6 @@ import {
|
||||
keybinds,
|
||||
type MultiSelectChipsAction,
|
||||
processMessage,
|
||||
serializeActionStates,
|
||||
type Stage,
|
||||
type ToggleAction,
|
||||
} from '@modrinth/moderation'
|
||||
@@ -486,13 +486,27 @@ import {
|
||||
type ProjectStatus,
|
||||
renderHighlightedString,
|
||||
} from '@modrinth/utils'
|
||||
import { computedAsync, useDebounceFn, useLocalStorage } from '@vueuse/core'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computedAsync, useDebounceFn } from '@vueuse/core'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
import { useImageUpload } from '~/composables/image-upload.ts'
|
||||
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||
import type { LockAcquireResponse } from '~/store/moderation.ts'
|
||||
import { useModerationStore } from '~/store/moderation.ts'
|
||||
import {
|
||||
clearChecklistProgressState,
|
||||
clearGeneratedMessageState as clearPersistedGeneratedMessageState,
|
||||
createEmptyGeneratedMessageState,
|
||||
loadChecklistActionStates,
|
||||
loadChecklistStage,
|
||||
loadChecklistTextInputs,
|
||||
loadGeneratedMessageState,
|
||||
saveChecklistActionStates,
|
||||
saveChecklistStage,
|
||||
saveChecklistTextInputs,
|
||||
saveGeneratedMessageState,
|
||||
} from '~/services/moderation-checklist-storage.ts'
|
||||
import { type LockAcquireResponse, useModerationQueue } from '~/services/moderation-queue.ts'
|
||||
|
||||
import KeybindsModal from './ChecklistKeybindsModal.vue'
|
||||
import ModpackPermissionsFlow from './ModpackPermissionsFlow.vue'
|
||||
@@ -508,9 +522,10 @@ const props = defineProps<{
|
||||
collapsed: boolean
|
||||
}>()
|
||||
|
||||
const { projectV2, projectV3 } = injectProjectPageContext()
|
||||
const { projectV2, projectV3, invalidate } = injectProjectPageContext()
|
||||
|
||||
const moderationStore = useModerationStore()
|
||||
const moderationQueue = useModerationQueue()
|
||||
const queryClient = useQueryClient()
|
||||
const tags = useGeneratedState()
|
||||
const auth = await useAuth()
|
||||
|
||||
@@ -548,7 +563,7 @@ async function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'visible' && lockStatus.value?.isOwnLock) {
|
||||
// Immediately refresh the lock when returning to the tab
|
||||
// This handles cases where the heartbeat was throttled while backgrounded
|
||||
const refreshResult = await moderationStore.refreshLock()
|
||||
const refreshResult = await moderationQueue.refreshLock()
|
||||
if (!refreshResult.success) {
|
||||
handleLockLost(refreshResult)
|
||||
return
|
||||
@@ -594,7 +609,7 @@ function clearLockCountdown() {
|
||||
function startLockHeartbeat() {
|
||||
lockCheckInterval.value = setInterval(
|
||||
async () => {
|
||||
const result = await moderationStore.refreshLock()
|
||||
const result = await moderationQueue.refreshLock()
|
||||
if (!result.success) {
|
||||
handleLockLost(result)
|
||||
}
|
||||
@@ -667,7 +682,7 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
|
||||
|
||||
// Quick re-check if close to expiry (last 5 seconds of TTL)
|
||||
if (now - next.validatedAt > PREFETCH_STALE_MS - 5000) {
|
||||
const recheck = await moderationStore.checkLock(next.projectId)
|
||||
const recheck = await moderationQueue.checkLock(next.projectId)
|
||||
if (recheck.locked && !recheck.expired) {
|
||||
// Project got locked, remove from queue and try next
|
||||
prefetchQueue.value.shift()
|
||||
@@ -679,7 +694,9 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
|
||||
prefetchQueue.value.shift()
|
||||
|
||||
// Mark skipped projects as completed
|
||||
next.skippedIds.forEach((id) => moderationStore.completeCurrentProject(id, 'skipped'))
|
||||
await Promise.all(
|
||||
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
|
||||
)
|
||||
|
||||
if (next.skippedIds.length > 0) {
|
||||
addNotification({
|
||||
@@ -734,11 +751,32 @@ async function onUploadHandler(file: File) {
|
||||
}
|
||||
|
||||
const useSimpleEditor = ref(false)
|
||||
const message = ref('')
|
||||
const generatedMessage = ref(false)
|
||||
const checklistPersistenceProjectSlug = projectV2.value.slug
|
||||
const persistedGeneratedMessage = import.meta.client
|
||||
? await loadGeneratedMessageState(checklistPersistenceProjectSlug)
|
||||
: createEmptyGeneratedMessageState()
|
||||
const message = ref(
|
||||
typeof persistedGeneratedMessage.message === 'string' ? persistedGeneratedMessage.message : '',
|
||||
)
|
||||
const generatedMessage = ref(persistedGeneratedMessage.generated === true)
|
||||
const loadingMessage = ref(false)
|
||||
const done = ref(false)
|
||||
|
||||
function persistGeneratedMessageState() {
|
||||
void saveGeneratedMessageState(checklistPersistenceProjectSlug, {
|
||||
generated: generatedMessage.value,
|
||||
message: message.value,
|
||||
})
|
||||
}
|
||||
|
||||
function clearGeneratedMessageState() {
|
||||
generatedMessage.value = false
|
||||
message.value = ''
|
||||
void clearPersistedGeneratedMessageState(checklistPersistenceProjectSlug)
|
||||
}
|
||||
|
||||
watch([generatedMessage, message], persistGeneratedMessageState, { flush: 'sync' })
|
||||
|
||||
function handleModpackPermissionsComplete() {
|
||||
modpackPermissionsComplete.value = true
|
||||
}
|
||||
@@ -752,7 +790,7 @@ async function handleExit() {
|
||||
// Release if we own the lock, or if there was an error checking (we might still own it)
|
||||
const projectId = projectV2.value?.id
|
||||
if (projectId && (lockStatus.value?.isOwnLock || lockError.value)) {
|
||||
const released = await moderationStore.releaseLock(projectId)
|
||||
const released = await moderationQueue.releaseLock(projectId)
|
||||
if (!released && lockStatus.value?.isOwnLock) {
|
||||
console.warn('Failed to release moderation lock for project:', projectId)
|
||||
}
|
||||
@@ -770,7 +808,7 @@ async function confirmTakeOverOverride() {
|
||||
console.warn('[confirmTakeOverOverride] No project ID available')
|
||||
return
|
||||
}
|
||||
const result = await moderationStore.overrideLock(projectId)
|
||||
const result = await moderationQueue.overrideLock(projectId)
|
||||
|
||||
if (result.success) {
|
||||
addNotification({
|
||||
@@ -825,7 +863,7 @@ async function batchCheckLocksWithMetadata(
|
||||
projectIds.map(async (id) => {
|
||||
// Parallel: check lock AND fetch project metadata
|
||||
const [lockResponse, projectData] = await Promise.all([
|
||||
moderationStore.checkLock(id),
|
||||
moderationQueue.checkLock(id),
|
||||
useBaseFetch(`project/${id}`, { method: 'GET' }).catch(() => null),
|
||||
])
|
||||
|
||||
@@ -856,7 +894,7 @@ async function batchCheckLocksWithMetadata(
|
||||
// Maintain a queue of prefetched unlocked projects for instant navigation
|
||||
async function maintainPrefetchQueue() {
|
||||
if (isPrefetching.value) return
|
||||
if (!moderationStore.isQueueMode) return
|
||||
if (!moderationQueue.isQueueMode) return
|
||||
|
||||
const currentProjectId = projectV2.value?.id
|
||||
|
||||
@@ -879,7 +917,7 @@ async function maintainPrefetchQueue() {
|
||||
|
||||
// 4. Get remaining queue items (excluding current and already prefetched)
|
||||
const prefetchedIds = new Set(prefetchQueue.value.map((p) => p.projectId))
|
||||
const queueItems = [...moderationStore.currentQueue.items]
|
||||
const queueItems = [...moderationQueue.currentQueue.items]
|
||||
const currentIndex = currentProjectId ? queueItems.indexOf(currentProjectId) : -1
|
||||
const remainingItems =
|
||||
currentIndex >= 0 ? queueItems.slice(currentIndex + 1) : queueItems.slice(1)
|
||||
@@ -951,12 +989,12 @@ async function skipToNextProject() {
|
||||
return
|
||||
}
|
||||
debug('[skipToNextProject] Starting. Current project:', currentProjectId)
|
||||
debug('[skipToNextProject] Queue before complete:', [...moderationStore.currentQueue.items])
|
||||
debug('[skipToNextProject] Queue before complete:', [...moderationQueue.currentQueue.items])
|
||||
|
||||
moderationStore.completeCurrentProject(currentProjectId, 'skipped')
|
||||
await moderationQueue.completeCurrentProject(currentProjectId, 'skipped')
|
||||
|
||||
debug('[skipToNextProject] Queue after complete:', [...moderationStore.currentQueue.items])
|
||||
debug('[skipToNextProject] hasItems:', moderationStore.hasItems)
|
||||
debug('[skipToNextProject] Queue after complete:', [...moderationQueue.currentQueue.items])
|
||||
debug('[skipToNextProject] hasItems:', moderationQueue.hasItems)
|
||||
|
||||
// Use prefetched data if available
|
||||
if (await navigateToNextUnlockedProject()) {
|
||||
@@ -968,7 +1006,7 @@ async function skipToNextProject() {
|
||||
|
||||
// Fallback: batch check remaining projects with metadata (excluding current)
|
||||
const remainingIds: string[] = []
|
||||
const queueItems = moderationStore.currentQueue.items
|
||||
const queueItems = moderationQueue.currentQueue.items
|
||||
|
||||
// Build list of remaining projects, excluding current
|
||||
for (const id of queueItems) {
|
||||
@@ -1011,7 +1049,7 @@ async function skipToNextProject() {
|
||||
}
|
||||
return
|
||||
}
|
||||
moderationStore.completeCurrentProject(id, 'skipped')
|
||||
await moderationQueue.completeCurrentProject(id, 'skipped')
|
||||
skippedCount++
|
||||
}
|
||||
|
||||
@@ -1034,8 +1072,7 @@ function resetProgress() {
|
||||
textInputValues.value = {}
|
||||
|
||||
done.value = false
|
||||
generatedMessage.value = false
|
||||
message.value = ''
|
||||
clearGeneratedMessageState()
|
||||
loadingMessage.value = false
|
||||
|
||||
localStorage.removeItem(`modpack-permissions-${projectV2.value.id}`)
|
||||
@@ -1061,8 +1098,11 @@ function findFirstValidStage(): number {
|
||||
}
|
||||
|
||||
const currentStageObj = computed(() => checklist[currentStage.value])
|
||||
const currentStage = useLocalStorage(`moderation-stage-${projectV2.value.slug}`, () =>
|
||||
findFirstValidStage(),
|
||||
const persistedStage = import.meta.client
|
||||
? await loadChecklistStage(checklistPersistenceProjectSlug)
|
||||
: null
|
||||
const currentStage = ref(
|
||||
persistedStage !== null && checklist[persistedStage] ? persistedStage : findFirstValidStage(),
|
||||
)
|
||||
|
||||
const stageTextExpanded = computedAsync(async () => {
|
||||
@@ -1081,37 +1121,27 @@ const stageTextExpanded = computedAsync(async () => {
|
||||
return null
|
||||
}, null)
|
||||
|
||||
interface ActionState {
|
||||
selected: boolean
|
||||
value?: any
|
||||
}
|
||||
|
||||
const persistedActionStates = useLocalStorage(
|
||||
`moderation-actions-${projectV2.value.slug}`,
|
||||
{},
|
||||
{
|
||||
serializer: {
|
||||
read: (v: any) => (v ? deserializeActionStates(v) : {}),
|
||||
write: (v: any) => serializeActionStates(v),
|
||||
},
|
||||
},
|
||||
)
|
||||
const persistedActionStates = import.meta.client
|
||||
? await loadChecklistActionStates(checklistPersistenceProjectSlug)
|
||||
: {}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const persistedTextInputs = useLocalStorage(
|
||||
`moderation-inputs-${projectV2.value.slug}`,
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
const persistedTextInputs = import.meta.client
|
||||
? await loadChecklistTextInputs(checklistPersistenceProjectSlug)
|
||||
: {}
|
||||
|
||||
const actionStates = ref<Record<string, ActionState>>(persistedActionStates.value)
|
||||
const textInputValues = ref<Record<string, string>>(persistedTextInputs.value)
|
||||
const actionStates = ref<Record<string, ActionState>>(persistedActionStates)
|
||||
const textInputValues = ref<Record<string, string>>(persistedTextInputs)
|
||||
|
||||
const persistState = () => {
|
||||
persistedActionStates.value = actionStates.value
|
||||
persistedTextInputs.value = textInputValues.value
|
||||
void saveChecklistActionStates(checklistPersistenceProjectSlug, actionStates.value)
|
||||
void saveChecklistTextInputs(checklistPersistenceProjectSlug, textInputValues.value)
|
||||
}
|
||||
|
||||
watch(currentStage, (stage) => {
|
||||
void saveChecklistStage(checklistPersistenceProjectSlug, stage)
|
||||
})
|
||||
watch(actionStates, persistState, { deep: true })
|
||||
watch(textInputValues, persistState, { deep: true })
|
||||
|
||||
@@ -1141,7 +1171,7 @@ function handleKeybinds(event: KeyboardEvent) {
|
||||
isLoadingMessage: loadingMessage.value,
|
||||
isModpackPermissionsStage: isModpackPermissionsStage.value,
|
||||
|
||||
futureProjectCount: moderationStore.queueLength,
|
||||
futureProjectCount: moderationQueue.queueLength,
|
||||
visibleActionsCount: visibleActions.value.length,
|
||||
|
||||
focusedActionIndex: focusedActionIndex.value,
|
||||
@@ -1249,14 +1279,14 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
// Try to acquire lock
|
||||
const result = await moderationStore.acquireLock(projectV2.value.id)
|
||||
const result = await moderationQueue.acquireLock(projectV2.value.id)
|
||||
|
||||
if (result.success) {
|
||||
handleLockAcquired()
|
||||
} else if (result.locked_by) {
|
||||
// Actually locked by another moderator
|
||||
// In queue mode with more projects - auto-skip to next project
|
||||
if (moderationStore.isQueueMode && moderationStore.queueLength > 1) {
|
||||
if (moderationQueue.isQueueMode && moderationQueue.queueLength > 1) {
|
||||
addNotification({
|
||||
title: 'Project locked',
|
||||
text: `Skipped project locked by @${result.locked_by.username}.`,
|
||||
@@ -1317,7 +1347,7 @@ onUnmounted(() => {
|
||||
// Release lock if we own it (navigation away without explicit exit)
|
||||
const projectId = projectV2.value?.id
|
||||
if (projectId && lockStatus.value?.isOwnLock) {
|
||||
moderationStore.releaseLock(projectId)
|
||||
void moderationQueue.releaseLock(projectId)
|
||||
}
|
||||
|
||||
// Clear prefetch state to prevent memory leaks
|
||||
@@ -1809,8 +1839,7 @@ function nextStage() {
|
||||
}
|
||||
|
||||
function goBackToStages() {
|
||||
generatedMessage.value = false
|
||||
message.value = ''
|
||||
clearGeneratedMessageState()
|
||||
|
||||
let targetStage = checklist.length - 1
|
||||
while (targetStage >= 0) {
|
||||
@@ -1923,6 +1952,16 @@ function generateModpackMessage(allFiles: {
|
||||
}
|
||||
|
||||
const hasNextProject = ref(false)
|
||||
async function refreshModerationCaches(threadId?: string) {
|
||||
const refreshes: Promise<unknown>[] = [invalidate(), refreshNuxtData('moderation-projects')]
|
||||
|
||||
if (threadId) {
|
||||
refreshes.push(queryClient.invalidateQueries({ queryKey: ['thread', threadId] }))
|
||||
}
|
||||
|
||||
await Promise.allSettled(refreshes)
|
||||
}
|
||||
|
||||
async function sendMessage(status: ProjectStatus) {
|
||||
// Capture project data upfront to avoid null issues during async operations
|
||||
const projectId = projectV2.value?.id
|
||||
@@ -1966,10 +2005,12 @@ async function sendMessage(status: ProjectStatus) {
|
||||
})
|
||||
}
|
||||
|
||||
const willHaveNext = moderationStore.completeCurrentProject(projectId, 'completed')
|
||||
await refreshModerationCaches(threadId)
|
||||
|
||||
const willHaveNext = await moderationQueue.completeCurrentProject(projectId, 'completed')
|
||||
|
||||
await Promise.race([
|
||||
moderationStore.releaseLock(projectId),
|
||||
moderationQueue.releaseLock(projectId),
|
||||
new Promise((r) => setTimeout(r, 2000)),
|
||||
])
|
||||
|
||||
@@ -1977,6 +2018,7 @@ async function sendMessage(status: ProjectStatus) {
|
||||
// to avoid the race condition where done=true renders with hasNextProject=false
|
||||
hasNextProject.value = willHaveNext
|
||||
done.value = true
|
||||
clearGeneratedMessageState()
|
||||
} catch (error) {
|
||||
console.error('Error submitting moderation:', error)
|
||||
addNotification({
|
||||
@@ -2000,7 +2042,7 @@ async function endChecklist(status?: string) {
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (moderationStore.currentQueue.total > 1) {
|
||||
if (moderationQueue.currentQueue.total > 1) {
|
||||
addNotification({
|
||||
title: 'Moderation completed',
|
||||
text: `You have completed the moderation queue.`,
|
||||
@@ -2019,7 +2061,7 @@ async function endChecklist(status?: string) {
|
||||
// Fallback: batch check remaining projects with metadata
|
||||
const remainingIds: string[] = []
|
||||
const currentProjectId = projectV2.value?.id
|
||||
const queueItems = moderationStore.currentQueue.items
|
||||
const queueItems = moderationQueue.currentQueue.items
|
||||
|
||||
// Build list of remaining projects, excluding current
|
||||
for (const id of queueItems) {
|
||||
@@ -2064,7 +2106,7 @@ async function endChecklist(status?: string) {
|
||||
foundUnlocked = true
|
||||
break
|
||||
}
|
||||
moderationStore.completeCurrentProject(id, 'skipped')
|
||||
await moderationQueue.completeCurrentProject(id, 'skipped')
|
||||
skippedCount++
|
||||
}
|
||||
|
||||
@@ -2100,11 +2142,11 @@ async function skipCurrentProject() {
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
moderationStore.releaseLock(projectId),
|
||||
moderationQueue.releaseLock(projectId),
|
||||
new Promise((r) => setTimeout(r, 2000)),
|
||||
])
|
||||
|
||||
hasNextProject.value = moderationStore.completeCurrentProject(projectId, 'skipped')
|
||||
hasNextProject.value = await moderationQueue.completeCurrentProject(projectId, 'skipped')
|
||||
|
||||
await endChecklist('skipped')
|
||||
}
|
||||
@@ -2112,15 +2154,15 @@ async function skipCurrentProject() {
|
||||
function clearProjectLocalStorage() {
|
||||
localStorage.removeItem(`modpack-permissions-${projectV2.value.id}`)
|
||||
localStorage.removeItem(`modpack-permissions-index-${projectV2.value.id}`)
|
||||
localStorage.removeItem(`moderation-actions-${projectV2.value.slug}`)
|
||||
localStorage.removeItem(`moderation-inputs-${projectV2.value.slug}`)
|
||||
localStorage.removeItem(`moderation-stage-${projectV2.value.slug}`)
|
||||
|
||||
sessionStorage.removeItem(`modpack-permissions-data-${projectV2.value.id}`)
|
||||
sessionStorage.removeItem(`modpack-permissions-permanent-no-${projectV2.value.id}`)
|
||||
sessionStorage.removeItem(`modpack-permissions-updated-${projectV2.value.id}`)
|
||||
|
||||
void clearChecklistProgressState(checklistPersistenceProjectSlug)
|
||||
actionStates.value = {}
|
||||
textInputValues.value = {}
|
||||
clearGeneratedMessageState()
|
||||
}
|
||||
|
||||
const isLastVisibleStage = computed(() => {
|
||||
@@ -2169,7 +2211,7 @@ const stageOptions = computed<OverflowMenuOption[]>(() => {
|
||||
return options
|
||||
})
|
||||
|
||||
type StageOverflowSlotOption = OverflowMenuOption & { id: string; text: string }
|
||||
type StageOverflowSlotOption = OverflowMenuOption & { id: string; text: string; icon?: Component }
|
||||
|
||||
const stageOptionsForSlots = computed(() =>
|
||||
stageOptions.value.filter((opt): opt is StageOverflowSlotOption => 'id' in opt && 'text' in opt),
|
||||
|
||||
@@ -217,6 +217,14 @@
|
||||
hoverFilled: true,
|
||||
disabled: project.status === 'withheld',
|
||||
},
|
||||
{
|
||||
id: 'set-to-draft-reply',
|
||||
action: () => {
|
||||
sendReply('draft')
|
||||
},
|
||||
hoverFilled: true,
|
||||
disabled: project.status === 'draft',
|
||||
},
|
||||
{
|
||||
id: 'send-to-review-reply',
|
||||
action: () => {
|
||||
@@ -236,6 +244,14 @@
|
||||
hoverFilled: true,
|
||||
disabled: project.status === 'withheld',
|
||||
},
|
||||
{
|
||||
id: 'set-to-draft',
|
||||
action: () => {
|
||||
setStatus('draft')
|
||||
},
|
||||
hoverFilled: true,
|
||||
disabled: project.status === 'draft',
|
||||
},
|
||||
{
|
||||
id: 'send-to-review',
|
||||
action: () => {
|
||||
@@ -256,6 +272,14 @@
|
||||
<EyeOffIcon aria-hidden="true" />
|
||||
Withhold
|
||||
</template>
|
||||
<template #set-to-draft-reply>
|
||||
<FileTextIcon aria-hidden="true" />
|
||||
Set to draft with reply
|
||||
</template>
|
||||
<template #set-to-draft>
|
||||
<FileTextIcon aria-hidden="true" />
|
||||
Set to draft
|
||||
</template>
|
||||
<template #send-to-review-reply>
|
||||
<ScaleIcon aria-hidden="true" />
|
||||
Send to review with reply
|
||||
@@ -280,6 +304,7 @@ import {
|
||||
CheckIcon,
|
||||
DropdownIcon,
|
||||
EyeOffIcon,
|
||||
FileTextIcon,
|
||||
ReplyIcon,
|
||||
ScaleIcon,
|
||||
SendIcon,
|
||||
@@ -417,7 +442,7 @@ async function sendReply(status = null, privateMessage = false) {
|
||||
|
||||
await updateThreadLocal()
|
||||
if (status !== null) {
|
||||
props.setStatus(status)
|
||||
await props.setStatus(status)
|
||||
}
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { getChangelog, type ChangelogEntry, type Product, type VersionEntry } from '@modrinth/blog'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export interface AppRelease {
|
||||
version: string
|
||||
publishedAt: string
|
||||
url: string
|
||||
}
|
||||
|
||||
function resolveChangelogEntry(
|
||||
entry: ChangelogEntry,
|
||||
appReleaseByVersion: Map<string, AppRelease>,
|
||||
): VersionEntry | null {
|
||||
if (entry.date) {
|
||||
return entry as VersionEntry
|
||||
}
|
||||
|
||||
if (entry.product !== 'app' || !entry.version) {
|
||||
return null
|
||||
}
|
||||
|
||||
const release = appReleaseByVersion.get(entry.version)
|
||||
if (!release) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
date: dayjs(release.publishedAt),
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveChangelogEntries(appReleases: AppRelease[] = []): VersionEntry[] {
|
||||
const appReleaseByVersion = new Map(appReleases.map((release) => [release.version, release]))
|
||||
|
||||
return getChangelog().flatMap((entry) => {
|
||||
const resolvedEntry = resolveChangelogEntry(entry, appReleaseByVersion)
|
||||
return resolvedEntry ? [resolvedEntry] : []
|
||||
})
|
||||
}
|
||||
|
||||
export function findChangelogEntry(
|
||||
entries: VersionEntry[],
|
||||
productParam: string | string[],
|
||||
dateParam: string | string[],
|
||||
): VersionEntry | undefined {
|
||||
const product = (Array.isArray(productParam) ? productParam[0] : productParam) as Product
|
||||
const date = Array.isArray(dateParam) ? dateParam[0] : dateParam
|
||||
|
||||
return entries.find((entry) => {
|
||||
if (entry.product !== product) return false
|
||||
if (entry.version && entry.version === date) return true
|
||||
return entry.date.unix() === Number(date)
|
||||
})
|
||||
}
|
||||
@@ -1121,9 +1121,21 @@
|
||||
"hosting-marketing.faq.burst-threads": {
|
||||
"message": "كيف تعمل مسارات المعالجة في وحدة المعالجة المركزية؟"
|
||||
},
|
||||
"hosting-marketing.faq.burst-threads.answer": {
|
||||
"message": "عندما يكون الخادم لديك تحت ضغطٍ عالٍ، نقوم مؤقتًا بمنحه وصولًا إلى خيوط معالجة إضافية (CPU threads) للمساعدة في تقليل التقطّعات وعدم الاستقرار. يساعد ذلك على منع انخفاض معدل النبضات في الثانية (TPS) إلى أقل من 20، مما يضمن تجربة سلسة قدر الإمكان. ونظرًا لأن خيوط المعالجة الإضافية هذه تكون متاحة لفترة قصيرة فقط أثناء فترات الضغط العالي، فقد لا تظهر في تقارير Spark أو أدوات تحليل الأداء الأخرى.\n"
|
||||
},
|
||||
"hosting-marketing.faq.cpu-kind.answer": {
|
||||
"message": "تعمل استضافة خوادم مودرينث على AMD Ryzen 7900 و 7950X3D و وحدات معالجة مركزية مماثلة بسرعة 5+ غيغاهيرتز مقترنة بذاكرة وصول عشوائي من نوع DDR5"
|
||||
},
|
||||
"hosting-marketing.faq.currency.answer": {
|
||||
"message": "جميع الأسعار مُدرجة بالدولار الأمريكي (USD)."
|
||||
},
|
||||
"hosting-marketing.faq.heading": {
|
||||
"message": "الأسئلة شائعه"
|
||||
},
|
||||
"hosting-marketing.faq.how-fast": {
|
||||
"message": "ما سرعه خوادم modrinth؟"
|
||||
},
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "أين تقع استضافة خوادم Modrinth؟ هل يمكنني اختيار منطقة؟"
|
||||
},
|
||||
@@ -1145,6 +1157,9 @@
|
||||
"hosting-marketing.included.backups-included": {
|
||||
"message": "النسخ الاحتياطية مضمنة"
|
||||
},
|
||||
"hosting-marketing.included.custom-url": {
|
||||
"message": "رابط مخصص"
|
||||
},
|
||||
"hosting-marketing.included.description": {
|
||||
"message": "."
|
||||
},
|
||||
@@ -1334,6 +1349,21 @@
|
||||
"landing.subheading": {
|
||||
"message": "اكتشف محتوى Minecraft والعب وشاركه من خلال منصتنا مفتوحة المصدر المصممة خصيصًا للمجتمع."
|
||||
},
|
||||
"layout.action.new-project": {
|
||||
"message": "مشروع جديد"
|
||||
},
|
||||
"layout.action.new-server-project": {
|
||||
"message": "خادم جديد"
|
||||
},
|
||||
"layout.action.publish": {
|
||||
"message": "نشر"
|
||||
},
|
||||
"layout.action.review-projects": {
|
||||
"message": "مراجعه المشروع"
|
||||
},
|
||||
"layout.avatar.alt": {
|
||||
"message": "صورتك الرمزية"
|
||||
},
|
||||
"layout.banner.add-email.description": {
|
||||
"message": "لأسباب أمنية، يحتاج مودرنث منك تسجيل عنوان بريد إلكتروني في حسابك."
|
||||
},
|
||||
|
||||
@@ -542,6 +542,9 @@
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "Vytvořit novou"
|
||||
},
|
||||
"dashboard.collections.label.projects-count": {
|
||||
"message": "{count} {countPlural, plural, one {project} other {projects}}"
|
||||
},
|
||||
"dashboard.collections.label.search-input": {
|
||||
"message": "Hledat ve vašich kolekcích"
|
||||
},
|
||||
@@ -551,6 +554,12 @@
|
||||
"dashboard.creator-tax-form-modal.confirmation.title": {
|
||||
"message": "Hotovo! 🎉"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.entity.foreign-entity": {
|
||||
"message": "Zahraniční subjekt"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.header": {
|
||||
"message": "Daňový formulář"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.us-citizen.question": {
|
||||
"message": "Jste občan USA?"
|
||||
},
|
||||
|
||||
@@ -554,6 +554,9 @@
|
||||
"dashboard.affiliate-links.create.button": {
|
||||
"message": "Partnerlink erstellen"
|
||||
},
|
||||
"dashboard.affiliate-links.empty.no-codes": {
|
||||
"message": "Keine Partnercodes gefunden."
|
||||
},
|
||||
"dashboard.affiliate-links.error.title": {
|
||||
"message": "Fehler beim laden von Partnerlinks"
|
||||
},
|
||||
@@ -572,6 +575,15 @@
|
||||
"dashboard.affiliate-links.search": {
|
||||
"message": "Partnerlinks durchsuchen..."
|
||||
},
|
||||
"dashboard.analytics.from-projects": {
|
||||
"message": "von {count} {count, plural, one {Projekt} other {Projekten}}"
|
||||
},
|
||||
"dashboard.analytics.total-downloads": {
|
||||
"message": "Gesamtzahl der Downloads"
|
||||
},
|
||||
"dashboard.analytics.total-followers": {
|
||||
"message": "Gesamtzahl der Follower"
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "Erstellen"
|
||||
},
|
||||
@@ -596,6 +608,18 @@
|
||||
"dashboard.collections.long-title": {
|
||||
"message": "Deine Kollektionen"
|
||||
},
|
||||
"dashboard.collections.placeholder.search": {
|
||||
"message": "Kollektionen durchsuchen..."
|
||||
},
|
||||
"dashboard.collections.sort.name-ascending": {
|
||||
"message": "Name (A-Z)"
|
||||
},
|
||||
"dashboard.collections.sort.recently-created": {
|
||||
"message": "Kürzlich erstellt"
|
||||
},
|
||||
"dashboard.collections.sort.recently-updated": {
|
||||
"message": "Kürzlich aktualisiert"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.download-button": {
|
||||
"message": "{formType} herunterladen"
|
||||
},
|
||||
@@ -848,6 +872,168 @@
|
||||
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
|
||||
"message": "Du hast dein Auszahlungslimit von <b>{withdrawLimit}</b> aufgebraucht. Du musst ein Steuerformular ausfüllen, um merh abzuheben."
|
||||
},
|
||||
"dashboard.head-title": {
|
||||
"message": "Dashboard"
|
||||
},
|
||||
"dashboard.notifications.button.mark-all-as-read": {
|
||||
"message": "Alle als gelesen markieren"
|
||||
},
|
||||
"dashboard.notifications.button.view-history": {
|
||||
"message": "Verlauf ansehen"
|
||||
},
|
||||
"dashboard.notifications.empty.no-unread": {
|
||||
"message": "Du hast keine ungelesenen Nachrichten."
|
||||
},
|
||||
"dashboard.notifications.error.loading": {
|
||||
"message": "Fehler beim laden von Benachrichtigungen:"
|
||||
},
|
||||
"dashboard.notifications.history.label": {
|
||||
"message": "Verlauf"
|
||||
},
|
||||
"dashboard.notifications.history.title": {
|
||||
"message": "Benachrichtigungsverlauf"
|
||||
},
|
||||
"dashboard.notifications.link.see-all": {
|
||||
"message": "Alle ansehen"
|
||||
},
|
||||
"dashboard.notifications.link.view-history": {
|
||||
"message": "Benachrichtigungsverlauf ansehen"
|
||||
},
|
||||
"dashboard.notifications.link.view-more": {
|
||||
"message": "Siehe {extraNotifs} weitere {extraNotifs, plural, one {Benachrichtigung} other {Benachrichtigungen}}"
|
||||
},
|
||||
"dashboard.notifications.loading": {
|
||||
"message": "Lade Benachrichtigungen..."
|
||||
},
|
||||
"dashboard.organizations.button.create": {
|
||||
"message": "Organisation erstellen"
|
||||
},
|
||||
"dashboard.organizations.empty.cta": {
|
||||
"message": "Erstelle eine Organisation!"
|
||||
},
|
||||
"dashboard.organizations.error.fetch": {
|
||||
"message": "Fehler beim laden der Organisationen"
|
||||
},
|
||||
"dashboard.organizations.member-count": {
|
||||
"message": "{count} {count, plural, one {Mitglied} other {Mitglieder}}"
|
||||
},
|
||||
"dashboard.organizations.title": {
|
||||
"message": "Organisationen"
|
||||
},
|
||||
"dashboard.projects.bulk-edit-hint": {
|
||||
"message": "Du kannst mehrere Projekte gleichzeitig bearbeiten, indem du sie unden auswählst."
|
||||
},
|
||||
"dashboard.projects.bulk-edit.server-disabled": {
|
||||
"message": "Serverprojekte understützen Massenbearbeitung nicht"
|
||||
},
|
||||
"dashboard.projects.empty": {
|
||||
"message": "Du hast noch keine Projekte. Drücke auf den grünen Knopf oben um anzufangen."
|
||||
},
|
||||
"dashboard.projects.head-title": {
|
||||
"message": "Projekte"
|
||||
},
|
||||
"dashboard.projects.links.and-more": {
|
||||
"message": "und {count} weitere..."
|
||||
},
|
||||
"dashboard.projects.links.button.clear-link": {
|
||||
"message": "Link entfernen"
|
||||
},
|
||||
"dashboard.projects.links.button.edit": {
|
||||
"message": "Links bearbeiten"
|
||||
},
|
||||
"dashboard.projects.links.changes-applied": {
|
||||
"message": "Änderungen werden auf <strong>{count}</strong> {count, plural, one {Projekt} other {Projekte}} angewendet."
|
||||
},
|
||||
"dashboard.projects.links.description": {
|
||||
"message": "Jeder Link welchen du unten angibst wird in jedem der ausgewählten Projekte überschrieben. Sämtliche leergelassenen werden ignoriert. Du kannst einen Link von allen ausgewählten Projekten entfernen, indem du auf den Mülleimer-Knopf drückst."
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.description": {
|
||||
"message": "Ein Einladungslink zu deinem Discordserver."
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.label": {
|
||||
"message": "Discord Einladung"
|
||||
},
|
||||
"dashboard.projects.links.issue-tracker.description": {
|
||||
"message": "Ein Ort für Benutzer um Bugs, Probleme und Bedenken über dein Projekt zu melden."
|
||||
},
|
||||
"dashboard.projects.links.issue-tracker.label": {
|
||||
"message": "Issue Tracker"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.cleared": {
|
||||
"message": "Existierende Links werden entfernt"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-discord-url": {
|
||||
"message": "Gib eine gültige Discord Einladungs-URL ein"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-url": {
|
||||
"message": "Gib eine gültige URL ein"
|
||||
},
|
||||
"dashboard.projects.links.show-all-projects": {
|
||||
"message": "Alle Projekte anzeigen"
|
||||
},
|
||||
"dashboard.projects.links.source-code.description": {
|
||||
"message": "Eine Seite/Repository welche den Quellcode deines Projekts enthält"
|
||||
},
|
||||
"dashboard.projects.links.source-code.label": {
|
||||
"message": "Quellcode"
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.description": {
|
||||
"message": "Eine Seite welche Informationen, Dokumentationen und Hilfe für dein Projekt enthält."
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.label": {
|
||||
"message": "Wikiseite"
|
||||
},
|
||||
"dashboard.projects.notification.bulk-edit-success": {
|
||||
"message": "Links von Projekten wurden massenbearbeitet."
|
||||
},
|
||||
"dashboard.projects.project.icon-alt": {
|
||||
"message": "Icon für {title}"
|
||||
},
|
||||
"dashboard.projects.project.moderator-message-aria": {
|
||||
"message": "Projekt hat eine Nachricht von den Moderatoren. Schaue dir das Projekt an um mehr zu sehen."
|
||||
},
|
||||
"dashboard.projects.project.review-environment-metadata": {
|
||||
"message": "Bitte überprüfe die Umgebungsmetadaten"
|
||||
},
|
||||
"dashboard.projects.sort.ascending": {
|
||||
"message": "Aufsteigend"
|
||||
},
|
||||
"dashboard.projects.sort.descending": {
|
||||
"message": "Absteigend"
|
||||
},
|
||||
"dashboard.projects.sort.option.name": {
|
||||
"message": "Name"
|
||||
},
|
||||
"dashboard.projects.sort.option.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"dashboard.projects.sort.option.type": {
|
||||
"message": "Art"
|
||||
},
|
||||
"dashboard.projects.table.icon": {
|
||||
"message": "Icon"
|
||||
},
|
||||
"dashboard.projects.table.id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"dashboard.projects.table.name": {
|
||||
"message": "Name"
|
||||
},
|
||||
"dashboard.projects.table.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"dashboard.projects.table.type": {
|
||||
"message": "Art"
|
||||
},
|
||||
"dashboard.report.title": {
|
||||
"message": "{id} melden"
|
||||
},
|
||||
"dashboard.reports.active-title": {
|
||||
"message": "Aktive Meldungen"
|
||||
},
|
||||
"dashboard.reports.title": {
|
||||
"message": "Meldungen"
|
||||
},
|
||||
"dashboard.revenue.available-now": {
|
||||
"message": "Jetzt verfügbar"
|
||||
},
|
||||
@@ -884,6 +1070,9 @@
|
||||
"dashboard.revenue.transactions.btn.download-csv": {
|
||||
"message": "Als CSV herunterladen"
|
||||
},
|
||||
"dashboard.revenue.transactions.head-title": {
|
||||
"message": "Zahlungsverlauf"
|
||||
},
|
||||
"dashboard.revenue.transactions.header": {
|
||||
"message": "Transaktionen"
|
||||
},
|
||||
@@ -893,9 +1082,18 @@
|
||||
"dashboard.revenue.transactions.none.desc": {
|
||||
"message": "Deine Auszahlungen und Abhebungen erscheinen hier."
|
||||
},
|
||||
"dashboard.revenue.transactions.period.last-month": {
|
||||
"message": "Letzen Monat"
|
||||
},
|
||||
"dashboard.revenue.transactions.period.this-month": {
|
||||
"message": "Diesen Monat"
|
||||
},
|
||||
"dashboard.revenue.transactions.see-all": {
|
||||
"message": "Alle anzeigen"
|
||||
},
|
||||
"dashboard.revenue.transactions.year.all": {
|
||||
"message": "Ganzes Jahr"
|
||||
},
|
||||
"dashboard.revenue.withdraw.blocked-tin-mismatch": {
|
||||
"message": "Deine Auszahlungen sind vorrübergehend gesperrt, da deine TIN oder SSN nicht mit den IRS-Aufzeichnungen übereinstimmt. Bitte kontaktiere den Support um dein Steuerformular zurückzusetzen und erneut einzureichen."
|
||||
},
|
||||
@@ -908,6 +1106,33 @@
|
||||
"dashboard.revenue.withdraw.header": {
|
||||
"message": "Abheben"
|
||||
},
|
||||
"dashboard.sidebar.label.activeReports": {
|
||||
"message": "Aktive Meldungen"
|
||||
},
|
||||
"dashboard.sidebar.label.analytics": {
|
||||
"message": "Analysen"
|
||||
},
|
||||
"dashboard.sidebar.label.creators": {
|
||||
"message": "Ersteller"
|
||||
},
|
||||
"dashboard.sidebar.label.dashboard": {
|
||||
"message": "Dashboard"
|
||||
},
|
||||
"dashboard.sidebar.label.notifications": {
|
||||
"message": "Benachrichtigungen"
|
||||
},
|
||||
"dashboard.sidebar.label.organizations": {
|
||||
"message": "Organisationen"
|
||||
},
|
||||
"dashboard.sidebar.label.overview": {
|
||||
"message": "Überblick"
|
||||
},
|
||||
"dashboard.sidebar.label.projects": {
|
||||
"message": "Projekte"
|
||||
},
|
||||
"dashboard.sidebar.label.revenue": {
|
||||
"message": "Einnahmen"
|
||||
},
|
||||
"dashboard.withdraw.completion.account": {
|
||||
"message": "Konto"
|
||||
},
|
||||
|
||||
@@ -596,6 +596,9 @@
|
||||
"dashboard.collections.long-title": {
|
||||
"message": "Deine Kollektionen"
|
||||
},
|
||||
"dashboard.collections.sort.name-ascending": {
|
||||
"message": "Name (A-Z)"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.download-button": {
|
||||
"message": "Lade {formType} herunter"
|
||||
},
|
||||
@@ -848,6 +851,57 @@
|
||||
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
|
||||
"message": "Du hast dein Auszahlungslimit von <b>{withdrawLimit}</b> aufgebraucht. Du musst ein Steuerformular ausfüllen, um merh abzuheben."
|
||||
},
|
||||
"dashboard.head-title": {
|
||||
"message": "Dashboard"
|
||||
},
|
||||
"dashboard.notifications.button.mark-all-as-read": {
|
||||
"message": "Alle als gelesen markieren"
|
||||
},
|
||||
"dashboard.projects.head-title": {
|
||||
"message": "Projekte"
|
||||
},
|
||||
"dashboard.projects.links.and-more": {
|
||||
"message": "und {count} weitere..."
|
||||
},
|
||||
"dashboard.projects.links.show-all-projects": {
|
||||
"message": "Alle Projekte anzeigen"
|
||||
},
|
||||
"dashboard.projects.links.source-code.label": {
|
||||
"message": "Quellcode"
|
||||
},
|
||||
"dashboard.projects.sort.ascending": {
|
||||
"message": "Aufsteigend"
|
||||
},
|
||||
"dashboard.projects.sort.option.name": {
|
||||
"message": "Name"
|
||||
},
|
||||
"dashboard.projects.sort.option.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"dashboard.projects.sort.option.type": {
|
||||
"message": "Typ"
|
||||
},
|
||||
"dashboard.projects.table.icon": {
|
||||
"message": "Icon"
|
||||
},
|
||||
"dashboard.projects.table.id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"dashboard.projects.table.name": {
|
||||
"message": "Name"
|
||||
},
|
||||
"dashboard.projects.table.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"dashboard.projects.table.type": {
|
||||
"message": "Typ"
|
||||
},
|
||||
"dashboard.reports.active-title": {
|
||||
"message": "Aktive Meldungen"
|
||||
},
|
||||
"dashboard.reports.title": {
|
||||
"message": "Meldungen"
|
||||
},
|
||||
"dashboard.revenue.available-now": {
|
||||
"message": "Jetzt verfügbar"
|
||||
},
|
||||
@@ -884,6 +938,9 @@
|
||||
"dashboard.revenue.transactions.btn.download-csv": {
|
||||
"message": "Als CSV herunterladen"
|
||||
},
|
||||
"dashboard.revenue.transactions.head-title": {
|
||||
"message": "Zahlungsverlauf"
|
||||
},
|
||||
"dashboard.revenue.transactions.header": {
|
||||
"message": "Transaktionen"
|
||||
},
|
||||
@@ -893,9 +950,18 @@
|
||||
"dashboard.revenue.transactions.none.desc": {
|
||||
"message": "Deine Auszahlungen und Abhebungen erscheinen hier."
|
||||
},
|
||||
"dashboard.revenue.transactions.period.last-month": {
|
||||
"message": "Letzen Monat"
|
||||
},
|
||||
"dashboard.revenue.transactions.period.this-month": {
|
||||
"message": "Diesen Monat"
|
||||
},
|
||||
"dashboard.revenue.transactions.see-all": {
|
||||
"message": "Alle anzeigen"
|
||||
},
|
||||
"dashboard.revenue.transactions.year.all": {
|
||||
"message": "Ganzes Jahr"
|
||||
},
|
||||
"dashboard.revenue.withdraw.blocked-tin-mismatch": {
|
||||
"message": "Deine Auszahlungen sind vorrübergehend gesperrt, da deine TIN oder SSN nicht mit den IRS-Aufzeichnungen übereinstimmt. Bitte kontaktiere den Support um dein Steuerformular zurückzusetzen und erneut einzureichen."
|
||||
},
|
||||
@@ -908,6 +974,33 @@
|
||||
"dashboard.revenue.withdraw.header": {
|
||||
"message": "Auszahlen"
|
||||
},
|
||||
"dashboard.sidebar.label.activeReports": {
|
||||
"message": "Aktive Meldungen"
|
||||
},
|
||||
"dashboard.sidebar.label.analytics": {
|
||||
"message": "Analysen"
|
||||
},
|
||||
"dashboard.sidebar.label.creators": {
|
||||
"message": "Ersteller"
|
||||
},
|
||||
"dashboard.sidebar.label.dashboard": {
|
||||
"message": "Dashboard"
|
||||
},
|
||||
"dashboard.sidebar.label.notifications": {
|
||||
"message": "Benachrichtigungen"
|
||||
},
|
||||
"dashboard.sidebar.label.organizations": {
|
||||
"message": "Organisationen"
|
||||
},
|
||||
"dashboard.sidebar.label.overview": {
|
||||
"message": "Übersicht"
|
||||
},
|
||||
"dashboard.sidebar.label.projects": {
|
||||
"message": "Projekte"
|
||||
},
|
||||
"dashboard.sidebar.label.revenue": {
|
||||
"message": "Einnahmen"
|
||||
},
|
||||
"dashboard.withdraw.completion.account": {
|
||||
"message": "Konto"
|
||||
},
|
||||
|
||||
@@ -554,6 +554,9 @@
|
||||
"dashboard.affiliate-links.create.button": {
|
||||
"message": "Create affiliate link"
|
||||
},
|
||||
"dashboard.affiliate-links.empty.no-codes": {
|
||||
"message": "No affiliate codes found."
|
||||
},
|
||||
"dashboard.affiliate-links.error.title": {
|
||||
"message": "Error loading affiliate links"
|
||||
},
|
||||
@@ -572,6 +575,15 @@
|
||||
"dashboard.affiliate-links.search": {
|
||||
"message": "Search affiliate links..."
|
||||
},
|
||||
"dashboard.analytics.from-projects": {
|
||||
"message": "from {count} {count, plural, one {project} other {projects}}"
|
||||
},
|
||||
"dashboard.analytics.total-downloads": {
|
||||
"message": "Total downloads"
|
||||
},
|
||||
"dashboard.analytics.total-followers": {
|
||||
"message": "Total followers"
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "Create new"
|
||||
},
|
||||
@@ -596,6 +608,18 @@
|
||||
"dashboard.collections.long-title": {
|
||||
"message": "Your collections"
|
||||
},
|
||||
"dashboard.collections.placeholder.search": {
|
||||
"message": "Search collections..."
|
||||
},
|
||||
"dashboard.collections.sort.name-ascending": {
|
||||
"message": "Name (A-Z)"
|
||||
},
|
||||
"dashboard.collections.sort.recently-created": {
|
||||
"message": "Recently Created"
|
||||
},
|
||||
"dashboard.collections.sort.recently-updated": {
|
||||
"message": "Recently Updated"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.download-button": {
|
||||
"message": "Download {formType}"
|
||||
},
|
||||
@@ -848,6 +872,168 @@
|
||||
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
|
||||
"message": "You've used up your <b>{withdrawLimit}</b> withdrawal limit. You must complete a tax form to withdraw more."
|
||||
},
|
||||
"dashboard.head-title": {
|
||||
"message": "Dashboard"
|
||||
},
|
||||
"dashboard.notifications.button.mark-all-as-read": {
|
||||
"message": "Mark all as read"
|
||||
},
|
||||
"dashboard.notifications.button.view-history": {
|
||||
"message": "View history"
|
||||
},
|
||||
"dashboard.notifications.empty.no-unread": {
|
||||
"message": "You don't have any unread notifications."
|
||||
},
|
||||
"dashboard.notifications.error.loading": {
|
||||
"message": "Error loading notifications:"
|
||||
},
|
||||
"dashboard.notifications.history.label": {
|
||||
"message": "History"
|
||||
},
|
||||
"dashboard.notifications.history.title": {
|
||||
"message": "Notification history"
|
||||
},
|
||||
"dashboard.notifications.link.see-all": {
|
||||
"message": "See all"
|
||||
},
|
||||
"dashboard.notifications.link.view-history": {
|
||||
"message": "View notification history"
|
||||
},
|
||||
"dashboard.notifications.link.view-more": {
|
||||
"message": "View {extraNotifs} more {extraNotifs, plural, one {notification} other {notifications}}"
|
||||
},
|
||||
"dashboard.notifications.loading": {
|
||||
"message": "Loading notifications..."
|
||||
},
|
||||
"dashboard.organizations.button.create": {
|
||||
"message": "Create organization"
|
||||
},
|
||||
"dashboard.organizations.empty.cta": {
|
||||
"message": "Make an organization!"
|
||||
},
|
||||
"dashboard.organizations.error.fetch": {
|
||||
"message": "Failed to fetch organizations"
|
||||
},
|
||||
"dashboard.organizations.member-count": {
|
||||
"message": "{count} {count, plural, one {member} other {members}}"
|
||||
},
|
||||
"dashboard.organizations.title": {
|
||||
"message": "Organizations"
|
||||
},
|
||||
"dashboard.projects.bulk-edit-hint": {
|
||||
"message": "You can edit multiple projects at once by selecting them below."
|
||||
},
|
||||
"dashboard.projects.bulk-edit.server-disabled": {
|
||||
"message": "Server projects do not support bulk editing"
|
||||
},
|
||||
"dashboard.projects.empty": {
|
||||
"message": "You don't have any projects yet. Click the green button above to begin."
|
||||
},
|
||||
"dashboard.projects.head-title": {
|
||||
"message": "Projects"
|
||||
},
|
||||
"dashboard.projects.links.and-more": {
|
||||
"message": "and {count} more..."
|
||||
},
|
||||
"dashboard.projects.links.button.clear-link": {
|
||||
"message": "Clear link"
|
||||
},
|
||||
"dashboard.projects.links.button.edit": {
|
||||
"message": "Edit links"
|
||||
},
|
||||
"dashboard.projects.links.changes-applied": {
|
||||
"message": "Changes will be applied to <strong>{count}</strong> {count, plural, one {project} other {projects}}."
|
||||
},
|
||||
"dashboard.projects.links.description": {
|
||||
"message": "Any links you specify below will be overwritten on each of the selected projects. Any you leave blank will be ignored. You can clear a link from all selected projects using the trash can button."
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.description": {
|
||||
"message": "An invitation link to your Discord server."
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.label": {
|
||||
"message": "Discord invite"
|
||||
},
|
||||
"dashboard.projects.links.issue-tracker.description": {
|
||||
"message": "A place for users to report bugs, issues, and concerns about your project."
|
||||
},
|
||||
"dashboard.projects.links.issue-tracker.label": {
|
||||
"message": "Issue tracker"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.cleared": {
|
||||
"message": "Existing link will be cleared"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-discord-url": {
|
||||
"message": "Enter a valid Discord invite URL"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-url": {
|
||||
"message": "Enter a valid URL"
|
||||
},
|
||||
"dashboard.projects.links.show-all-projects": {
|
||||
"message": "Show all projects"
|
||||
},
|
||||
"dashboard.projects.links.source-code.description": {
|
||||
"message": "A page/repository containing the source code for your project"
|
||||
},
|
||||
"dashboard.projects.links.source-code.label": {
|
||||
"message": "Source code"
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.description": {
|
||||
"message": "A page containing information, documentation, and help for the project."
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.label": {
|
||||
"message": "Wiki page"
|
||||
},
|
||||
"dashboard.projects.notification.bulk-edit-success": {
|
||||
"message": "Bulk edited selected project's links."
|
||||
},
|
||||
"dashboard.projects.project.icon-alt": {
|
||||
"message": "Icon for {title}"
|
||||
},
|
||||
"dashboard.projects.project.moderator-message-aria": {
|
||||
"message": "Project has a message from the moderators. View the project to see more."
|
||||
},
|
||||
"dashboard.projects.project.review-environment-metadata": {
|
||||
"message": "Please review environment metadata"
|
||||
},
|
||||
"dashboard.projects.sort.ascending": {
|
||||
"message": "Ascending"
|
||||
},
|
||||
"dashboard.projects.sort.descending": {
|
||||
"message": "Descending"
|
||||
},
|
||||
"dashboard.projects.sort.option.name": {
|
||||
"message": "Name"
|
||||
},
|
||||
"dashboard.projects.sort.option.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"dashboard.projects.sort.option.type": {
|
||||
"message": "Type"
|
||||
},
|
||||
"dashboard.projects.table.icon": {
|
||||
"message": "Icon"
|
||||
},
|
||||
"dashboard.projects.table.id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"dashboard.projects.table.name": {
|
||||
"message": "Name"
|
||||
},
|
||||
"dashboard.projects.table.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"dashboard.projects.table.type": {
|
||||
"message": "Type"
|
||||
},
|
||||
"dashboard.report.title": {
|
||||
"message": "Report {id}"
|
||||
},
|
||||
"dashboard.reports.active-title": {
|
||||
"message": "Active reports"
|
||||
},
|
||||
"dashboard.reports.title": {
|
||||
"message": "Reports"
|
||||
},
|
||||
"dashboard.revenue.available-now": {
|
||||
"message": "Available now"
|
||||
},
|
||||
@@ -884,6 +1070,9 @@
|
||||
"dashboard.revenue.transactions.btn.download-csv": {
|
||||
"message": "Download as CSV"
|
||||
},
|
||||
"dashboard.revenue.transactions.head-title": {
|
||||
"message": "Transaction history"
|
||||
},
|
||||
"dashboard.revenue.transactions.header": {
|
||||
"message": "Transactions"
|
||||
},
|
||||
@@ -893,9 +1082,18 @@
|
||||
"dashboard.revenue.transactions.none.desc": {
|
||||
"message": "Your payouts and withdrawals will appear here."
|
||||
},
|
||||
"dashboard.revenue.transactions.period.last-month": {
|
||||
"message": "Last month"
|
||||
},
|
||||
"dashboard.revenue.transactions.period.this-month": {
|
||||
"message": "This month"
|
||||
},
|
||||
"dashboard.revenue.transactions.see-all": {
|
||||
"message": "See all"
|
||||
},
|
||||
"dashboard.revenue.transactions.year.all": {
|
||||
"message": "All years"
|
||||
},
|
||||
"dashboard.revenue.withdraw.blocked-tin-mismatch": {
|
||||
"message": "Your withdrawals are temporarily locked because your TIN or SSN didn't match IRS records. Please contact support to reset and resubmit your tax form."
|
||||
},
|
||||
@@ -908,6 +1106,33 @@
|
||||
"dashboard.revenue.withdraw.header": {
|
||||
"message": "Withdraw"
|
||||
},
|
||||
"dashboard.sidebar.label.activeReports": {
|
||||
"message": "Active reports"
|
||||
},
|
||||
"dashboard.sidebar.label.analytics": {
|
||||
"message": "Analytics"
|
||||
},
|
||||
"dashboard.sidebar.label.creators": {
|
||||
"message": "Creators"
|
||||
},
|
||||
"dashboard.sidebar.label.dashboard": {
|
||||
"message": "Dashboard"
|
||||
},
|
||||
"dashboard.sidebar.label.notifications": {
|
||||
"message": "Notifications"
|
||||
},
|
||||
"dashboard.sidebar.label.organizations": {
|
||||
"message": "Organizations"
|
||||
},
|
||||
"dashboard.sidebar.label.overview": {
|
||||
"message": "Overview"
|
||||
},
|
||||
"dashboard.sidebar.label.projects": {
|
||||
"message": "Projects"
|
||||
},
|
||||
"dashboard.sidebar.label.revenue": {
|
||||
"message": "Revenue"
|
||||
},
|
||||
"dashboard.withdraw.completion.account": {
|
||||
"message": "Account"
|
||||
},
|
||||
|
||||
@@ -554,6 +554,9 @@
|
||||
"dashboard.affiliate-links.create.button": {
|
||||
"message": "Crear enlace de afiliado"
|
||||
},
|
||||
"dashboard.affiliate-links.empty.no-codes": {
|
||||
"message": "No se encontraron códigos de afiliado."
|
||||
},
|
||||
"dashboard.affiliate-links.error.title": {
|
||||
"message": "Error al cargar los enlaces de afiliado"
|
||||
},
|
||||
@@ -572,6 +575,15 @@
|
||||
"dashboard.affiliate-links.search": {
|
||||
"message": "Buscar enlaces de afiliado..."
|
||||
},
|
||||
"dashboard.analytics.from-projects": {
|
||||
"message": "de {count} {count, plural,one {proyecto} other {proyectos}}"
|
||||
},
|
||||
"dashboard.analytics.total-downloads": {
|
||||
"message": "Descargas totales"
|
||||
},
|
||||
"dashboard.analytics.total-followers": {
|
||||
"message": "Total de seguidores"
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "Crear nuevo"
|
||||
},
|
||||
@@ -3155,6 +3167,12 @@
|
||||
"settings.billing.interval.monthly": {
|
||||
"message": "mensual"
|
||||
},
|
||||
"settings.billing.interval.quarter": {
|
||||
"message": "trimestre"
|
||||
},
|
||||
"settings.billing.interval.quarterly.adjective": {
|
||||
"message": "trimestralmente"
|
||||
},
|
||||
"settings.billing.interval.year": {
|
||||
"message": "año"
|
||||
},
|
||||
@@ -3269,6 +3287,9 @@
|
||||
"settings.billing.pyro.resubscribe.error.title": {
|
||||
"message": "Error al renovar la suscripción"
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.request-submitted.text": {
|
||||
"message": "Si el servidor se encuentra cancelado, puede tomar 10-15 minutos en preparar el servidor"
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.request-submitted.title": {
|
||||
"message": "Solicitud de renovación de suscripción enviada"
|
||||
},
|
||||
|
||||
@@ -554,6 +554,9 @@
|
||||
"dashboard.affiliate-links.create.button": {
|
||||
"message": "Crear enlace de afiliado"
|
||||
},
|
||||
"dashboard.affiliate-links.empty.no-codes": {
|
||||
"message": "No se encontraron códigos de afiliado."
|
||||
},
|
||||
"dashboard.affiliate-links.error.title": {
|
||||
"message": "Error al cargar los enlaces de afiliado"
|
||||
},
|
||||
@@ -572,6 +575,15 @@
|
||||
"dashboard.affiliate-links.search": {
|
||||
"message": "Buscar enlaces de afiliado..."
|
||||
},
|
||||
"dashboard.analytics.from-projects": {
|
||||
"message": "de {count} {count, plural,one {proyecto} other {proyectos}}"
|
||||
},
|
||||
"dashboard.analytics.total-downloads": {
|
||||
"message": "Descargas totales"
|
||||
},
|
||||
"dashboard.analytics.total-followers": {
|
||||
"message": "Total de seguidores"
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "Crear nuevo"
|
||||
},
|
||||
@@ -3155,6 +3167,12 @@
|
||||
"settings.billing.interval.monthly": {
|
||||
"message": "mensual"
|
||||
},
|
||||
"settings.billing.interval.quarter": {
|
||||
"message": "trimestre"
|
||||
},
|
||||
"settings.billing.interval.quarterly.adjective": {
|
||||
"message": "trimestral"
|
||||
},
|
||||
"settings.billing.interval.year": {
|
||||
"message": "año"
|
||||
},
|
||||
@@ -3269,6 +3287,9 @@
|
||||
"settings.billing.pyro.resubscribe.error.title": {
|
||||
"message": "Error al volver a suscribirse"
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.request-submitted.text": {
|
||||
"message": "Si el servidor está actualmente cancelado, tomará 10-15 minutos en establecerse."
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.request-submitted.title": {
|
||||
"message": "Solicitud de renovación enviada"
|
||||
},
|
||||
|
||||
@@ -554,6 +554,9 @@
|
||||
"dashboard.affiliate-links.create.button": {
|
||||
"message": "Créer un lien affilié"
|
||||
},
|
||||
"dashboard.affiliate-links.empty.no-codes": {
|
||||
"message": "Aucun code d'affiliation trouvé."
|
||||
},
|
||||
"dashboard.affiliate-links.error.title": {
|
||||
"message": "Erreur lors du chargement des liens d'affiliation"
|
||||
},
|
||||
@@ -572,6 +575,15 @@
|
||||
"dashboard.affiliate-links.search": {
|
||||
"message": "Rechercher des liens d'affiliation..."
|
||||
},
|
||||
"dashboard.analytics.from-projects": {
|
||||
"message": "de {count} {count, plural, one {projet} other {projets}}"
|
||||
},
|
||||
"dashboard.analytics.total-downloads": {
|
||||
"message": "Total des téléchargements"
|
||||
},
|
||||
"dashboard.analytics.total-followers": {
|
||||
"message": "Total des adorateurs"
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "Créer un nouveau"
|
||||
},
|
||||
@@ -596,6 +608,18 @@
|
||||
"dashboard.collections.long-title": {
|
||||
"message": "Vos collections"
|
||||
},
|
||||
"dashboard.collections.placeholder.search": {
|
||||
"message": "Rechercher dans les collections..."
|
||||
},
|
||||
"dashboard.collections.sort.name-ascending": {
|
||||
"message": "Nom (A-Z)"
|
||||
},
|
||||
"dashboard.collections.sort.recently-created": {
|
||||
"message": "Créé récemment"
|
||||
},
|
||||
"dashboard.collections.sort.recently-updated": {
|
||||
"message": "Mis à jour récemment"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.download-button": {
|
||||
"message": "Télécharger {formType}"
|
||||
},
|
||||
@@ -848,6 +872,168 @@
|
||||
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
|
||||
"message": "Vous avez atteint votre limite de retrait de <b>{withdrawLimit}</b>. Vous devez remplir un formulaire fiscal pour effectuer un retrait supplémentaire."
|
||||
},
|
||||
"dashboard.head-title": {
|
||||
"message": "Tableau de bord"
|
||||
},
|
||||
"dashboard.notifications.button.mark-all-as-read": {
|
||||
"message": "Marquer tout comme lu"
|
||||
},
|
||||
"dashboard.notifications.button.view-history": {
|
||||
"message": "Voir l'historique"
|
||||
},
|
||||
"dashboard.notifications.empty.no-unread": {
|
||||
"message": "Vous n'avez pas de notifications non lues."
|
||||
},
|
||||
"dashboard.notifications.error.loading": {
|
||||
"message": "Échec lors du chargement des notifications :"
|
||||
},
|
||||
"dashboard.notifications.history.label": {
|
||||
"message": "Historique"
|
||||
},
|
||||
"dashboard.notifications.history.title": {
|
||||
"message": "Historique des notifications"
|
||||
},
|
||||
"dashboard.notifications.link.see-all": {
|
||||
"message": "Voir tout"
|
||||
},
|
||||
"dashboard.notifications.link.view-history": {
|
||||
"message": "Historique des notifications"
|
||||
},
|
||||
"dashboard.notifications.link.view-more": {
|
||||
"message": "Voir {extraNotifs} {extraNotifs, plural, one {notification} other {notifications}} de plus"
|
||||
},
|
||||
"dashboard.notifications.loading": {
|
||||
"message": "Chargement des notifications..."
|
||||
},
|
||||
"dashboard.organizations.button.create": {
|
||||
"message": "Créer une organisation"
|
||||
},
|
||||
"dashboard.organizations.empty.cta": {
|
||||
"message": "Faites une organisation !"
|
||||
},
|
||||
"dashboard.organizations.error.fetch": {
|
||||
"message": "Impossible de chercher des organisations"
|
||||
},
|
||||
"dashboard.organizations.member-count": {
|
||||
"message": "{count} {count, plural, one {membre} other {membres}}"
|
||||
},
|
||||
"dashboard.organizations.title": {
|
||||
"message": "Organisations"
|
||||
},
|
||||
"dashboard.projects.bulk-edit-hint": {
|
||||
"message": "Vous pouvez modifier plusieurs projets en même temps en les sélectionnant ci-dessous."
|
||||
},
|
||||
"dashboard.projects.bulk-edit.server-disabled": {
|
||||
"message": "Les projets du serveur ne prennent pas en charge l'édition en masse"
|
||||
},
|
||||
"dashboard.projects.empty": {
|
||||
"message": "Vous n'avez aucun projet. Cliquez sur le bouton vert ci-dessus pour commencer."
|
||||
},
|
||||
"dashboard.projects.head-title": {
|
||||
"message": "Projets"
|
||||
},
|
||||
"dashboard.projects.links.and-more": {
|
||||
"message": "en ajouter {count} de plus..."
|
||||
},
|
||||
"dashboard.projects.links.button.clear-link": {
|
||||
"message": "Effacer le lien"
|
||||
},
|
||||
"dashboard.projects.links.button.edit": {
|
||||
"message": "Modifier les liens"
|
||||
},
|
||||
"dashboard.projects.links.changes-applied": {
|
||||
"message": "Les changements seront appliqués à <strong>{count}</strong> {count, plural,one {projet}other {projets}}."
|
||||
},
|
||||
"dashboard.projects.links.description": {
|
||||
"message": "Tous les liens que vous spécifiez ci-dessous seront écrasés sur chacun des projets sélectionnés. Tout ce que vous laissez en blanc sera ignoré. Vous pouvez effacer un lien de tous les projets sélectionnés à l'aide du bouton de la poubelle."
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.description": {
|
||||
"message": "Une invitation en lien avec votre serveur Discord."
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.label": {
|
||||
"message": "Invitation Discord"
|
||||
},
|
||||
"dashboard.projects.links.issue-tracker.description": {
|
||||
"message": "Un endroit pour que les utilisateurs signalent des bugs, problèmes et préoccupations à propos de votre projet."
|
||||
},
|
||||
"dashboard.projects.links.issue-tracker.label": {
|
||||
"message": "Traceur de problèmes"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.cleared": {
|
||||
"message": "Un lien existant sera nettoyé"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-discord-url": {
|
||||
"message": "Entrez un URL Discord valide"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-url": {
|
||||
"message": "Entrez un URL valide"
|
||||
},
|
||||
"dashboard.projects.links.show-all-projects": {
|
||||
"message": "Montrer tous les projets"
|
||||
},
|
||||
"dashboard.projects.links.source-code.description": {
|
||||
"message": "Une page/dépôt contenant le code source pour votre projet"
|
||||
},
|
||||
"dashboard.projects.links.source-code.label": {
|
||||
"message": "Code source"
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.description": {
|
||||
"message": "Une page contenant des informations, de la documentation et de l'aide pour le projet."
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.label": {
|
||||
"message": "Page wiki"
|
||||
},
|
||||
"dashboard.projects.notification.bulk-edit-success": {
|
||||
"message": "Éditer en gros les liens sélectionnés."
|
||||
},
|
||||
"dashboard.projects.project.icon-alt": {
|
||||
"message": "Icône pour {title}"
|
||||
},
|
||||
"dashboard.projects.project.moderator-message-aria": {
|
||||
"message": "Le projet a un message des modérateurs. Regardez le projet pour voir plus."
|
||||
},
|
||||
"dashboard.projects.project.review-environment-metadata": {
|
||||
"message": "Merci de revoir les métadonnées environnementales"
|
||||
},
|
||||
"dashboard.projects.sort.ascending": {
|
||||
"message": "Ascendant"
|
||||
},
|
||||
"dashboard.projects.sort.descending": {
|
||||
"message": "Descendant"
|
||||
},
|
||||
"dashboard.projects.sort.option.name": {
|
||||
"message": "Nom"
|
||||
},
|
||||
"dashboard.projects.sort.option.status": {
|
||||
"message": "Statut"
|
||||
},
|
||||
"dashboard.projects.sort.option.type": {
|
||||
"message": "Type"
|
||||
},
|
||||
"dashboard.projects.table.icon": {
|
||||
"message": "Icône"
|
||||
},
|
||||
"dashboard.projects.table.id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"dashboard.projects.table.name": {
|
||||
"message": "Nom"
|
||||
},
|
||||
"dashboard.projects.table.status": {
|
||||
"message": "Statut"
|
||||
},
|
||||
"dashboard.projects.table.type": {
|
||||
"message": "Type"
|
||||
},
|
||||
"dashboard.report.title": {
|
||||
"message": "Signaler {id}"
|
||||
},
|
||||
"dashboard.reports.active-title": {
|
||||
"message": "Signalements actifs"
|
||||
},
|
||||
"dashboard.reports.title": {
|
||||
"message": "Signalements"
|
||||
},
|
||||
"dashboard.revenue.available-now": {
|
||||
"message": "Disponible Maintenant"
|
||||
},
|
||||
@@ -884,6 +1070,9 @@
|
||||
"dashboard.revenue.transactions.btn.download-csv": {
|
||||
"message": "Télécharger comme CSV"
|
||||
},
|
||||
"dashboard.revenue.transactions.head-title": {
|
||||
"message": "Historique des transactions"
|
||||
},
|
||||
"dashboard.revenue.transactions.header": {
|
||||
"message": "Transactions"
|
||||
},
|
||||
@@ -893,9 +1082,18 @@
|
||||
"dashboard.revenue.transactions.none.desc": {
|
||||
"message": "Vos paiements et retraits apparaîtront ici."
|
||||
},
|
||||
"dashboard.revenue.transactions.period.last-month": {
|
||||
"message": "Dernier mois"
|
||||
},
|
||||
"dashboard.revenue.transactions.period.this-month": {
|
||||
"message": "Ce mois-ci"
|
||||
},
|
||||
"dashboard.revenue.transactions.see-all": {
|
||||
"message": "Voir tout"
|
||||
},
|
||||
"dashboard.revenue.transactions.year.all": {
|
||||
"message": "Tous les ans"
|
||||
},
|
||||
"dashboard.revenue.withdraw.blocked-tin-mismatch": {
|
||||
"message": "Vos retraits sont temporairement bloqués car votre NIF ou votre NSS ne correspond pas aux données de l'IRS. Veuillez contacter l'assistance pour réinitialiser votre compte et soumettre à nouveau votre déclaration de revenus."
|
||||
},
|
||||
@@ -908,6 +1106,33 @@
|
||||
"dashboard.revenue.withdraw.header": {
|
||||
"message": "Retirer"
|
||||
},
|
||||
"dashboard.sidebar.label.activeReports": {
|
||||
"message": "Signalements actifs"
|
||||
},
|
||||
"dashboard.sidebar.label.analytics": {
|
||||
"message": "Analytiques"
|
||||
},
|
||||
"dashboard.sidebar.label.creators": {
|
||||
"message": "Créateurs"
|
||||
},
|
||||
"dashboard.sidebar.label.dashboard": {
|
||||
"message": "Tableau de bord"
|
||||
},
|
||||
"dashboard.sidebar.label.notifications": {
|
||||
"message": "Notifications"
|
||||
},
|
||||
"dashboard.sidebar.label.organizations": {
|
||||
"message": "Organisations"
|
||||
},
|
||||
"dashboard.sidebar.label.overview": {
|
||||
"message": "Aperçu"
|
||||
},
|
||||
"dashboard.sidebar.label.projects": {
|
||||
"message": "Projets"
|
||||
},
|
||||
"dashboard.sidebar.label.revenue": {
|
||||
"message": "Revenu"
|
||||
},
|
||||
"dashboard.withdraw.completion.account": {
|
||||
"message": "Compte"
|
||||
},
|
||||
@@ -3057,7 +3282,7 @@
|
||||
"message": "Vous êtes sur le point de supprimer cette application et retirer tous les jetons d'accès. (pour toujours !)"
|
||||
},
|
||||
"settings.applications.delete.confirm.title": {
|
||||
"message": "Êtes-vous sûr.e de vouloir supprimer cette application ? "
|
||||
"message": "Êtes-vous sûr.e de vouloir supprimer cette application ?"
|
||||
},
|
||||
"settings.applications.description.intro": {
|
||||
"message": "Les applications permettent d'authentifier les utilisateurs de Modrinth auprès de vos produits. Pour plus d'informations, consultez la <docs-link>documentation de l'API Modrinth</docs-link>."
|
||||
@@ -3549,7 +3774,7 @@
|
||||
"message": "S'abonner"
|
||||
},
|
||||
"ui.newsletter-button.subscribed": {
|
||||
"message": "Abonné ! "
|
||||
"message": "Abonné !"
|
||||
},
|
||||
"ui.newsletter-button.tooltip": {
|
||||
"message": "S'abonner au journal Modrinth"
|
||||
|
||||
@@ -3155,6 +3155,12 @@
|
||||
"settings.billing.interval.monthly": {
|
||||
"message": "havi"
|
||||
},
|
||||
"settings.billing.interval.quarter": {
|
||||
"message": "negyedév"
|
||||
},
|
||||
"settings.billing.interval.quarterly.adjective": {
|
||||
"message": "negyedéves"
|
||||
},
|
||||
"settings.billing.interval.year": {
|
||||
"message": "év"
|
||||
},
|
||||
@@ -3266,15 +3272,72 @@
|
||||
"settings.billing.pyro.resubscribe.error.text": {
|
||||
"message": "Hiba történt a Modrinth szerverre való újbóli feliratkozás során."
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.error.title": {
|
||||
"message": "Hiba az újbóli feliratkozáskor"
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.request-submitted.text": {
|
||||
"message": "Amennyiben a szervere jelenleg le van mondva, akár 10-15 percig is eltarthat, hogy a szerver felálljon."
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.request-submitted.title": {
|
||||
"message": "Újra feliratkozási kérelem benyújtva"
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.success.text": {
|
||||
"message": "Szerver előfizetés sikeresen újrakezdve"
|
||||
},
|
||||
"settings.billing.pyro.status.failed": {
|
||||
"message": "Sikertelen fizetés. Kérjük frissítse a fizetési opcióját, majd próbálja újra."
|
||||
},
|
||||
"settings.billing.pyro.status.processing": {
|
||||
"message": "Fizetése feldolgozás alatt áll. A szervere aktiválódik amint a fizetés sikeres."
|
||||
},
|
||||
"settings.billing.pyro.storage": {
|
||||
"message": "{gb} GB SSD"
|
||||
},
|
||||
"settings.billing.pyro.swap": {
|
||||
"message": "{gb} GB Swap"
|
||||
},
|
||||
"settings.billing.pyro_subscription.description": {
|
||||
"message": "Modirinth szerver feliratkozások kezelése"
|
||||
},
|
||||
"settings.billing.pyro_subscription.title": {
|
||||
"message": "Modrinth Szerver Előfizetések"
|
||||
},
|
||||
"settings.billing.renews": {
|
||||
"message": "Megújul: {date}"
|
||||
},
|
||||
"settings.billing.resubscribe": {
|
||||
"message": "Feliratkozás újra"
|
||||
},
|
||||
"settings.billing.since": {
|
||||
"message": "{date} óta"
|
||||
},
|
||||
"settings.billing.subscribe": {
|
||||
"message": "Feliratkozás"
|
||||
},
|
||||
"settings.billing.subscription.description": {
|
||||
"message": "Modirinth feliratkozások kezelése"
|
||||
},
|
||||
"settings.billing.subscription.title": {
|
||||
"message": "Előfizetések"
|
||||
},
|
||||
"settings.billing.switch.switching-to-interval": {
|
||||
"message": "Váltás {interval} számlázásra"
|
||||
},
|
||||
"settings.billing.switch.to-interval": {
|
||||
"message": "Váltás {interval} számlázásra "
|
||||
},
|
||||
"settings.billing.switch.tooltip.monthly-additional-per-year": {
|
||||
"message": "A havi számlázás évente további {amount} költséggel jár"
|
||||
},
|
||||
"settings.billing.switches-to-billing-on": {
|
||||
"message": "{interval} számlázásra vált {date} napon"
|
||||
},
|
||||
"settings.billing.update-method": {
|
||||
"message": "Fizetési mód változtatása"
|
||||
},
|
||||
"settings.billing.upgrade": {
|
||||
"message": "Csomagváltás"
|
||||
},
|
||||
"settings.display.banner.developer-mode.button": {
|
||||
"message": "A fejlesztői mód letiltása"
|
||||
},
|
||||
@@ -3353,6 +3416,18 @@
|
||||
"settings.display.sidebar.hide-app-promos.title": {
|
||||
"message": "Modrinth App promóciók elrejtése"
|
||||
},
|
||||
"settings.display.sidebar.left-aligned-content-sidebar.title": {
|
||||
"message": "Balra igazított oldalsáv oldalakon"
|
||||
},
|
||||
"settings.display.sidebar.right-aligned-content-sidebar.description": {
|
||||
"message": "A tartalom bal oldalára igazítja az oldalsávot."
|
||||
},
|
||||
"settings.display.sidebar.right-aligned-filters-sidebar.description": {
|
||||
"message": "A keresési eredmények jobb oldalára igazítja a szűrőket."
|
||||
},
|
||||
"settings.display.sidebar.right-aligned-filters-sidebar.title": {
|
||||
"message": "Jobbra igazított szűrők oldalakon"
|
||||
},
|
||||
"settings.display.theme.description": {
|
||||
"message": "Válaszd ki a Modrinth számára a kívánt színsémát ezen az eszközön."
|
||||
},
|
||||
@@ -3365,6 +3440,9 @@
|
||||
"settings.pats.action.create": {
|
||||
"message": "PAT létrehozása"
|
||||
},
|
||||
"settings.pats.description": {
|
||||
"message": "A PAT-ek a Modrinth API-hoz való hozzáférést biztosítják. Bármikor létrehozhatók és visszavonhatók. További információkért látogasson el a <doc-link>Modrinth API dokumentációjára</doc-link>."
|
||||
},
|
||||
"settings.pats.modal.create.action": {
|
||||
"message": "PAT létrehozása"
|
||||
},
|
||||
|
||||
@@ -1392,7 +1392,7 @@
|
||||
"message": "server"
|
||||
},
|
||||
"landing.heading.the-place-for-minecraft.shaders": {
|
||||
"message": "pencorak"
|
||||
"message": "efek gambar"
|
||||
},
|
||||
"landing.launcher.atlauncher-label": {
|
||||
"message": "ATLauncher"
|
||||
@@ -1635,7 +1635,7 @@
|
||||
"message": "Hidup/matikan menu"
|
||||
},
|
||||
"layout.meta.description": {
|
||||
"message": "Unduh mod, pasang-masuk, paket data, pencorak, paket sumber, dan paket mod untuk Minecraft di Modrinth. Temukan dan terbitkan proyek di Modrinth dengan antarmuka dan API yang modern dan mudah digunakan."
|
||||
"message": "Unduh mod, pasang-masuk, paket data, efek gambar, paket sumber, dan paket mod untuk Minecraft di Modrinth. Temukan dan terbitkan proyek di Modrinth dengan antarmuka dan API yang modern dan mudah digunakan."
|
||||
},
|
||||
"layout.meta.og-description": {
|
||||
"message": "Temukan dan terbitkan konten Minecraft!"
|
||||
@@ -2181,10 +2181,10 @@
|
||||
"message": "Server"
|
||||
},
|
||||
"project-type.shader.plural": {
|
||||
"message": "Pencorak"
|
||||
"message": "Efek Gambar"
|
||||
},
|
||||
"project-type.shader.singular": {
|
||||
"message": "Pencorak"
|
||||
"message": "Efek Gambar"
|
||||
},
|
||||
"project.about.details.created": {
|
||||
"message": "Dibuat {date}"
|
||||
@@ -2763,7 +2763,7 @@
|
||||
"message": "Tulis versi"
|
||||
},
|
||||
"search.filter.game-version-shader-message": {
|
||||
"message": "Paket pencorak untuk versi lama kemungkinan besar dapat bekerja di versi yang lebih baru dengan beberapa masalah ringan."
|
||||
"message": "Paket efek gambar untuk versi lama kemungkinan besar dapat bekerja di versi yang lebih baru dengan beberapa masalah ringan."
|
||||
},
|
||||
"search.filter.locked.server": {
|
||||
"message": "Disediakan oleh server"
|
||||
@@ -3099,7 +3099,7 @@
|
||||
"message": "Halaman Server"
|
||||
},
|
||||
"settings.display.project-list-layouts.shader": {
|
||||
"message": "Halaman Pencorak (Shader)"
|
||||
"message": "Halaman Efek Gambar"
|
||||
},
|
||||
"settings.display.project-list-layouts.title": {
|
||||
"message": "Tata letak daftar proyek"
|
||||
|
||||
@@ -554,6 +554,9 @@
|
||||
"dashboard.affiliate-links.create.button": {
|
||||
"message": "Crea link affiliati"
|
||||
},
|
||||
"dashboard.affiliate-links.empty.no-codes": {
|
||||
"message": "Nessun codice affiliati trovato."
|
||||
},
|
||||
"dashboard.affiliate-links.error.title": {
|
||||
"message": "Errore nel caricamento dei link affiliati"
|
||||
},
|
||||
@@ -572,6 +575,15 @@
|
||||
"dashboard.affiliate-links.search": {
|
||||
"message": "Cerca link affiliati..."
|
||||
},
|
||||
"dashboard.analytics.from-projects": {
|
||||
"message": "da {count, plural, one {# progetto} other {# progetti}}"
|
||||
},
|
||||
"dashboard.analytics.total-downloads": {
|
||||
"message": "Download totali"
|
||||
},
|
||||
"dashboard.analytics.total-followers": {
|
||||
"message": "Follower totali"
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "Crea nuova"
|
||||
},
|
||||
@@ -596,6 +608,18 @@
|
||||
"dashboard.collections.long-title": {
|
||||
"message": "Le tue raccolte"
|
||||
},
|
||||
"dashboard.collections.placeholder.search": {
|
||||
"message": "Cerca tra le raccolte..."
|
||||
},
|
||||
"dashboard.collections.sort.name-ascending": {
|
||||
"message": "Nome (A-Z)"
|
||||
},
|
||||
"dashboard.collections.sort.recently-created": {
|
||||
"message": "Data di creazione"
|
||||
},
|
||||
"dashboard.collections.sort.recently-updated": {
|
||||
"message": "Ultima modifica"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.download-button": {
|
||||
"message": "Scarica {formType}"
|
||||
},
|
||||
@@ -848,6 +872,168 @@
|
||||
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
|
||||
"message": "Hai consumato l'intero tuo limite di <b>{withdrawLimit}</b>. Dovrai compilare un modulo fiscale per prelevare di più."
|
||||
},
|
||||
"dashboard.head-title": {
|
||||
"message": "Bacheca"
|
||||
},
|
||||
"dashboard.notifications.button.mark-all-as-read": {
|
||||
"message": "Segna tutte come lette"
|
||||
},
|
||||
"dashboard.notifications.button.view-history": {
|
||||
"message": "Mostra cronologia"
|
||||
},
|
||||
"dashboard.notifications.empty.no-unread": {
|
||||
"message": "Hai letto tutte le notifiche."
|
||||
},
|
||||
"dashboard.notifications.error.loading": {
|
||||
"message": "Impossibile caricare le notifiche:"
|
||||
},
|
||||
"dashboard.notifications.history.label": {
|
||||
"message": "Cronologia"
|
||||
},
|
||||
"dashboard.notifications.history.title": {
|
||||
"message": "Cronologia delle notifiche"
|
||||
},
|
||||
"dashboard.notifications.link.see-all": {
|
||||
"message": "Mostra tutte"
|
||||
},
|
||||
"dashboard.notifications.link.view-history": {
|
||||
"message": "Mostra cronologia notifiche"
|
||||
},
|
||||
"dashboard.notifications.link.view-more": {
|
||||
"message": "Mostra {extraNotifs, plural, one {un'altra notifica} other {altre # notifiche}}"
|
||||
},
|
||||
"dashboard.notifications.loading": {
|
||||
"message": "Caricando le notifiche..."
|
||||
},
|
||||
"dashboard.organizations.button.create": {
|
||||
"message": "Crea organizzazione"
|
||||
},
|
||||
"dashboard.organizations.empty.cta": {
|
||||
"message": "Crea un'organizzazione!"
|
||||
},
|
||||
"dashboard.organizations.error.fetch": {
|
||||
"message": "Impossibile ottenere le organizzazioni"
|
||||
},
|
||||
"dashboard.organizations.member-count": {
|
||||
"message": "{count, plural, one {# membro} other {# membri}}"
|
||||
},
|
||||
"dashboard.organizations.title": {
|
||||
"message": "Organizzazioni"
|
||||
},
|
||||
"dashboard.projects.bulk-edit-hint": {
|
||||
"message": "Puoi modificare più progetti contemporaneamente selezionandoli qui sotto."
|
||||
},
|
||||
"dashboard.projects.bulk-edit.server-disabled": {
|
||||
"message": "Impossibile modificare più server contemporaneamente"
|
||||
},
|
||||
"dashboard.projects.empty": {
|
||||
"message": "Non hai ancora alcun progetto. Clicca sul pulsante verde in alto per iniziare."
|
||||
},
|
||||
"dashboard.projects.head-title": {
|
||||
"message": "Progetti"
|
||||
},
|
||||
"dashboard.projects.links.and-more": {
|
||||
"message": "e {count, plural, one {un altro ancora} other {altri #}}..."
|
||||
},
|
||||
"dashboard.projects.links.button.clear-link": {
|
||||
"message": "Rimuovi link"
|
||||
},
|
||||
"dashboard.projects.links.button.edit": {
|
||||
"message": "Modifica link"
|
||||
},
|
||||
"dashboard.projects.links.changes-applied": {
|
||||
"message": "Le modifiche verranno applicate a <strong>{count}</strong> {count, plural, one {progetto} other {progetti}}."
|
||||
},
|
||||
"dashboard.projects.links.description": {
|
||||
"message": "Qualsiasi link specificato sotto sarà modificato per tutti i progetti selezionati. Quelli lasciati in bianco saranno ignorati. Puoi rimuovere un link da tutti i progetti selezionati col pulsante Rimuovi."
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.description": {
|
||||
"message": "Un link di invito al tuo server Discord."
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.label": {
|
||||
"message": "Invito a Discord"
|
||||
},
|
||||
"dashboard.projects.links.issue-tracker.description": {
|
||||
"message": "Un posto dove gli utenti potranno segnalare bug o problemi riguardo al tuo progetto."
|
||||
},
|
||||
"dashboard.projects.links.issue-tracker.label": {
|
||||
"message": "Issue tracker"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.cleared": {
|
||||
"message": "I link esistenti saranno rimossi"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-discord-url": {
|
||||
"message": "Inserisci un invito a Discord valido"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-url": {
|
||||
"message": "Inserisci un URL valido"
|
||||
},
|
||||
"dashboard.projects.links.show-all-projects": {
|
||||
"message": "Mostra tutti i progetti"
|
||||
},
|
||||
"dashboard.projects.links.source-code.description": {
|
||||
"message": "Una pagina o repository che contiene il codice sorgente del tuo progetto"
|
||||
},
|
||||
"dashboard.projects.links.source-code.label": {
|
||||
"message": "Codice sorgente"
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.description": {
|
||||
"message": "Una pagina che contiene informazioni, documentazioni e/o guide per il tuo progetto."
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.label": {
|
||||
"message": "Pagina wiki"
|
||||
},
|
||||
"dashboard.projects.notification.bulk-edit-success": {
|
||||
"message": "Modifiche applicate con successo."
|
||||
},
|
||||
"dashboard.projects.project.icon-alt": {
|
||||
"message": "Icona per {title}"
|
||||
},
|
||||
"dashboard.projects.project.moderator-message-aria": {
|
||||
"message": "Il progetto ha ricevuto un messaggio dai moderatori. Visitalo per leggerlo."
|
||||
},
|
||||
"dashboard.projects.project.review-environment-metadata": {
|
||||
"message": "Si prega di rivedere i metadati d'ambiente"
|
||||
},
|
||||
"dashboard.projects.sort.ascending": {
|
||||
"message": "Crescente"
|
||||
},
|
||||
"dashboard.projects.sort.descending": {
|
||||
"message": "Decrescente"
|
||||
},
|
||||
"dashboard.projects.sort.option.name": {
|
||||
"message": "Nome"
|
||||
},
|
||||
"dashboard.projects.sort.option.status": {
|
||||
"message": "Stato"
|
||||
},
|
||||
"dashboard.projects.sort.option.type": {
|
||||
"message": "Tipo"
|
||||
},
|
||||
"dashboard.projects.table.icon": {
|
||||
"message": "Icona"
|
||||
},
|
||||
"dashboard.projects.table.id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"dashboard.projects.table.name": {
|
||||
"message": "Nome"
|
||||
},
|
||||
"dashboard.projects.table.status": {
|
||||
"message": "Stato"
|
||||
},
|
||||
"dashboard.projects.table.type": {
|
||||
"message": "Tipo"
|
||||
},
|
||||
"dashboard.report.title": {
|
||||
"message": "Segnala {id}"
|
||||
},
|
||||
"dashboard.reports.active-title": {
|
||||
"message": "Segnalazioni aperte"
|
||||
},
|
||||
"dashboard.reports.title": {
|
||||
"message": "Segnalazioni"
|
||||
},
|
||||
"dashboard.revenue.available-now": {
|
||||
"message": "Disponibile ora"
|
||||
},
|
||||
@@ -884,6 +1070,9 @@
|
||||
"dashboard.revenue.transactions.btn.download-csv": {
|
||||
"message": "Scarica come CSV"
|
||||
},
|
||||
"dashboard.revenue.transactions.head-title": {
|
||||
"message": "Storico delle transazioni"
|
||||
},
|
||||
"dashboard.revenue.transactions.header": {
|
||||
"message": "Transazioni"
|
||||
},
|
||||
@@ -893,9 +1082,18 @@
|
||||
"dashboard.revenue.transactions.none.desc": {
|
||||
"message": "I tuoi pagamenti e prelievi appariranno qui."
|
||||
},
|
||||
"dashboard.revenue.transactions.period.last-month": {
|
||||
"message": "Mese scorso"
|
||||
},
|
||||
"dashboard.revenue.transactions.period.this-month": {
|
||||
"message": "Questo mese"
|
||||
},
|
||||
"dashboard.revenue.transactions.see-all": {
|
||||
"message": "Mostra tutto"
|
||||
},
|
||||
"dashboard.revenue.transactions.year.all": {
|
||||
"message": "Tutte"
|
||||
},
|
||||
"dashboard.revenue.withdraw.blocked-tin-mismatch": {
|
||||
"message": "I tuoi prelievi sono temporaneamente bloccati perché il tuo TIN o SSN non corrisponde ai registri dell'IRS. Contatta l'assistenza per resettare e reinviare il tuo modulo fiscale."
|
||||
},
|
||||
@@ -908,6 +1106,33 @@
|
||||
"dashboard.revenue.withdraw.header": {
|
||||
"message": "Preleva"
|
||||
},
|
||||
"dashboard.sidebar.label.activeReports": {
|
||||
"message": "Segnalazioni aperte"
|
||||
},
|
||||
"dashboard.sidebar.label.analytics": {
|
||||
"message": "Analitiche"
|
||||
},
|
||||
"dashboard.sidebar.label.creators": {
|
||||
"message": "Creatori"
|
||||
},
|
||||
"dashboard.sidebar.label.dashboard": {
|
||||
"message": "Bacheca"
|
||||
},
|
||||
"dashboard.sidebar.label.notifications": {
|
||||
"message": "Notifiche"
|
||||
},
|
||||
"dashboard.sidebar.label.organizations": {
|
||||
"message": "Organizzazioni"
|
||||
},
|
||||
"dashboard.sidebar.label.overview": {
|
||||
"message": "Riepilogo"
|
||||
},
|
||||
"dashboard.sidebar.label.projects": {
|
||||
"message": "Progetti"
|
||||
},
|
||||
"dashboard.sidebar.label.revenue": {
|
||||
"message": "Guadagni"
|
||||
},
|
||||
"dashboard.withdraw.completion.account": {
|
||||
"message": "Account"
|
||||
},
|
||||
@@ -2217,7 +2442,7 @@
|
||||
"message": "Rivedi progetto"
|
||||
},
|
||||
"project.actions.servers-promo.description": {
|
||||
"message": "Modrinth Hosting è il modo più facile per invitare i tuoi amici a giocare spensieratamente!"
|
||||
"message": "Modrinth Hosting è il modo più facile per giocare insieme spensieratamente!"
|
||||
},
|
||||
"project.actions.servers-promo.pricing": {
|
||||
"message": "A partire da {price}<small> al mese</small>"
|
||||
|
||||
@@ -657,7 +657,7 @@
|
||||
"message": "순수령액"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.fee-breakdown-usd-equivalent": {
|
||||
"message": "USD 환산액"
|
||||
"message": "달러 환산액"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.kyc.business-entity": {
|
||||
"message": "사업체"
|
||||
@@ -1125,10 +1125,10 @@
|
||||
"message": "서버가 과부하 상태일 때, 일시적으로 추가 CPU 스레드를 제공하여 지연과 불안정성을 완화합니다. 이를 통해 TPS가 20 밑으로 떨어지지 않도록 하여 가능한 한 부드러운 환경을 유지할 수 있습니다. 단, 이러한 추가 CPU 스레드는 부하가 높은 순간에만 잠시 제공되므로 Spark 보고서나 다른 프로파일링 도구에는 나타나지 않을 수 있습니다."
|
||||
},
|
||||
"hosting-marketing.faq.cpu-kind": {
|
||||
"message": "Modrinth Hosting 서버는 어떤 종류의 CPU를 사용하나요?"
|
||||
"message": "Modrinth 호스팅 서버는 어떤 종류의 CPU를 사용하나요?"
|
||||
},
|
||||
"hosting-marketing.faq.cpu-kind.answer": {
|
||||
"message": "Modrinth Hosting 서버는 5GHz 이상의 AMD Ryzen 7900 및 7950X3D급 CPU와 DDR5 메모리로 구동됩니다."
|
||||
"message": "Modrinth 호스팅 서버는 5GHz 이상의 AMD Ryzen 7900 및 7950X3D급 CPU와 DDR5 메모리로 구동됩니다."
|
||||
},
|
||||
"hosting-marketing.faq.currency": {
|
||||
"message": "가격은 어떤 통화로 표시되나요?"
|
||||
@@ -1137,7 +1137,7 @@
|
||||
"message": "모든 가격은 미국 달러(USD)로 표시됩니다."
|
||||
},
|
||||
"hosting-marketing.faq.ddos-protection": {
|
||||
"message": "Modrinth Hosting 서버는 DDoS 공격 방어 기능을 갖추고 있나요?"
|
||||
"message": "Modrinth 호스팅 서버는 DDoS 공격 방어 기능을 갖추고 있나요?"
|
||||
},
|
||||
"hosting-marketing.faq.ddos-protection.answer": {
|
||||
"message": "네. 모든 Modrinth 호스팅 서버에는 DDoS 공격 방지 기능이 탑재되어 있으며, 일부 지역에서는 최대 17 Tbps의 용량을 제공합니다."
|
||||
@@ -1161,7 +1161,7 @@
|
||||
"message": "네, 서버 저장 용량은 추가 비용 없이 늘릴 수 있습니다. 더 많은 저장 용량이 필요하시면 Modrinth 지원팀에 문의해 주세요."
|
||||
},
|
||||
"hosting-marketing.faq.location": {
|
||||
"message": "Modrinth Hosting 서버는 어디에 위치해 있나요? 지역을 선택할 수 있나요?"
|
||||
"message": "Modrinth 호스팅 서버는 어디에 위치해 있나요? 지역을 선택할 수 있나요?"
|
||||
},
|
||||
"hosting-marketing.faq.location.answer": {
|
||||
"message": "현재 서버는 구매 시 북미, 유럽, 동남아 지역 중에서 선택할 수 있습니다. 향후 더 많은 지역에서 서비스를 제공할 예정입니다! 지역을 변경이 필요하면 지원팀에 문의해 주세요."
|
||||
@@ -1188,10 +1188,10 @@
|
||||
"message": "새 서버 시작하기"
|
||||
},
|
||||
"hosting-marketing.hero.host-with-modrinth": {
|
||||
"message": "다음 서버는 Modrinth Hosting으로 시작하세요"
|
||||
"message": "당신의 다음 서버를 Modrinth 호스팅으로 시작하세요"
|
||||
},
|
||||
"hosting-marketing.hero.hosting-description": {
|
||||
"message": "Modrinth Hosting은 Minecraft: Java Edition 서버를 운영하는 가장 쉬운 방법입니다. Modrinth 플랫폼 내에서 좋아하는 모드와 모드팩을 간편하게 설치하고 플레이하세요."
|
||||
"message": "Modrinth 호스팅은 마인크래프트 자바에디션 서버를 운영하는 가장 쉬운 방법입니다. Modrinth 플랫폼 내에서 좋아하는 모드와 모드팩을 간편하게 설치하고 플레이하세요."
|
||||
},
|
||||
"hosting-marketing.included.advanced-networking": {
|
||||
"message": "고급 네트워킹 관리"
|
||||
@@ -1236,13 +1236,13 @@
|
||||
"message": "Modrinth Hosting은 서버 관리를 위한 강력한 도구를 제공합니다."
|
||||
},
|
||||
"hosting-marketing.included.sftp-access": {
|
||||
"message": "SFTP 액세스"
|
||||
"message": "SFTP 엑세스"
|
||||
},
|
||||
"hosting-marketing.included.sftp-access.description": {
|
||||
"message": "Modrinth Hosting에 내장된 SFTP를 사용하여 서버 파일에 직접 접근하세요."
|
||||
"message": "Modrinth 호스팅에 내장된 SFTP를 사용하여 서버 파일에 직접 접근하세요."
|
||||
},
|
||||
"hosting-marketing.included.with-your-server": {
|
||||
"message": "서버에 포함되어 있습니다."
|
||||
"message": "당신의 서버에 포함되어 있습니다"
|
||||
},
|
||||
"hosting-marketing.know-what-you-need": {
|
||||
"message": "정확히 필요한 것이 있나요?"
|
||||
@@ -1257,7 +1257,7 @@
|
||||
"message": "기간 한정 특가. 신용카드 필요 없음. 미국 서버에서 사용 가능."
|
||||
},
|
||||
"hosting-marketing.pick-customized-plan": {
|
||||
"message": "필요한 사양만 포함된 맞춤형 플랜을 선택하세요."
|
||||
"message": "당신이 필요한 사양만 포함된 맞춤형 플랜을 선택하세요."
|
||||
},
|
||||
"hosting-marketing.server-for-everyone": {
|
||||
"message": "모두를 위한 서버가 있습니다"
|
||||
@@ -1290,10 +1290,10 @@
|
||||
"message": "모드가 있는 곳에서 플레이하세요"
|
||||
},
|
||||
"hosting-marketing.why.where-mods-are.description": {
|
||||
"message": "Modrinth Hosting은 모드 및 모드팩 설치 과정을 서버에 완벽하게 통합합니다."
|
||||
"message": "Modrinth 호스팅은 모드 및 모드팩 설치 과정을 서버에 완벽하게 통합합니다."
|
||||
},
|
||||
"hosting-marketing.why.why-modrinth-hosting": {
|
||||
"message": "Modrinth Hosting을 선택하는 이유는 무엇일까요?"
|
||||
"message": "Modrinth 호스팅을 선택하는 이유는 무엇입니까?"
|
||||
},
|
||||
"hosting-marketing.why.your-favorite-mods": {
|
||||
"message": "좋아하는 모드로 플레이하세요"
|
||||
@@ -1371,7 +1371,7 @@
|
||||
"message": "원하는 프로젝트를 쉽고 빠르게 찾으세요"
|
||||
},
|
||||
"landing.heading.the-place-for-minecraft": {
|
||||
"message": "Minecraft {content} 위한 공간"
|
||||
"message": "마인크래프트 {content} 위한 공간"
|
||||
},
|
||||
"landing.heading.the-place-for-minecraft.data-packs": {
|
||||
"message": "데이터 팩을"
|
||||
@@ -1401,7 +1401,7 @@
|
||||
"message": "Minecraft 창을 단순화한 표현으로, Modrinth 그린 색상의 Mojang Studios 로고가 포함되어 있습니다."
|
||||
},
|
||||
"landing.launcher.modrinth-app-label": {
|
||||
"message": "Modrinth App"
|
||||
"message": "Modrinth 앱"
|
||||
},
|
||||
"landing.launcher.prism-launcher-label": {
|
||||
"message": "Prism Launcher"
|
||||
@@ -1599,7 +1599,7 @@
|
||||
"message": "Modrinth+"
|
||||
},
|
||||
"layout.footer.products.servers": {
|
||||
"message": "Modrinth Hosting"
|
||||
"message": "Modrinth 호스팅"
|
||||
},
|
||||
"layout.footer.resources": {
|
||||
"message": "정보"
|
||||
@@ -1635,10 +1635,10 @@
|
||||
"message": "메뉴 전환"
|
||||
},
|
||||
"layout.meta.description": {
|
||||
"message": "Modrinth에서 Minecraft 모드, 플러그인, 데이터 팩, 셰이더, 리소스 팩, 모드팩을 다운로드하세요. 현대적이고 사용하기 쉬운 인터페이스와 API를 통해 Modrinth에서 프로젝트를 탐색하고 게시하세요."
|
||||
"message": "Modrinth에서 마인크래프트 모드, 플러그인, 데이터 팩, 셰이더, 리소스 팩, 모드팩을 다운로드하세요. 현대적이고 사용하기 쉬운 인터페이스와 API를 통해 Modrinth에서 프로젝트를 탐색하고 게시하세요."
|
||||
},
|
||||
"layout.meta.og-description": {
|
||||
"message": "Minecraft 콘텐츠를 탐색하고 게시하세요!"
|
||||
"message": "마인크래프트 콘텐츠를 탐색하고 게시하세요!"
|
||||
},
|
||||
"layout.mobile.close-menu": {
|
||||
"message": "메뉴 닫기"
|
||||
@@ -1656,7 +1656,7 @@
|
||||
"message": "콘텐츠 탐색하기"
|
||||
},
|
||||
"layout.nav.get-modrinth-app": {
|
||||
"message": "Modrinth App 받기"
|
||||
"message": "Modrinth 앱 받기"
|
||||
},
|
||||
"layout.nav.home": {
|
||||
"message": "홈"
|
||||
@@ -1665,7 +1665,7 @@
|
||||
"message": "서버 호스트하기"
|
||||
},
|
||||
"layout.nav.modrinth-app": {
|
||||
"message": "Modrinth App"
|
||||
"message": "Modrinth 앱"
|
||||
},
|
||||
"layout.nav.modrinth-home-page": {
|
||||
"message": "Modrinth 홈페이지"
|
||||
@@ -1692,7 +1692,7 @@
|
||||
"message": "검색"
|
||||
},
|
||||
"layout.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Modrinth+로 업그레이드"
|
||||
"message": "Modrinth+ 로 업그레이드"
|
||||
},
|
||||
"moderation.moderate": {
|
||||
"message": "관리"
|
||||
@@ -2217,7 +2217,7 @@
|
||||
"message": "프로젝트 검토"
|
||||
},
|
||||
"project.actions.servers-promo.description": {
|
||||
"message": "Modrinth Hosting은 친구들과 번거로움 없이 플레이할 수 있는 가장 쉬운 방법입니다!"
|
||||
"message": "Modrinth 호스팅은 친구들과 번거로움 없이 플레이할 수 있는 가장 쉬운 방법입니다!"
|
||||
},
|
||||
"project.actions.servers-promo.pricing": {
|
||||
"message": "{price}<small> / 월</small>부터 시작"
|
||||
@@ -2253,7 +2253,7 @@
|
||||
"message": "Modrinth App으로 설치"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Modrinth App을 설치하지 않으셨나요?"
|
||||
"message": "Modrinth 앱을 설치하지 않으셨나요?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "{gameVersion} 및 {platform}용 버전이 없습니다."
|
||||
@@ -2463,7 +2463,7 @@
|
||||
"message": "악성 또는 기만적인 콘텐츠에 대한 신고에는 코드 샘플과 같은 행위에 대한 실질적 증거가 포함되어야 합니다."
|
||||
},
|
||||
"report.note.malicious.2": {
|
||||
"message": "Microsoft Defender, VirusTotal 또는 AI 멀웨어 탐지의 요약은 충분한 증거가 아니므로 허용되지 않습니다."
|
||||
"message": "마이크로소프트 디펜더, VirusTotal 또는 인공지능 멀웨어 탐지의 요약은 충분한 증거가 아니므로 허용되지 않습니다."
|
||||
},
|
||||
"report.please-report": {
|
||||
"message": "해당 상황에서 신고하세요:"
|
||||
@@ -2982,7 +2982,7 @@
|
||||
"message": "이 백업 코드들을 안전한 곳에 저장해 두세요. 이 백업 코드들은 기기를 사용할 수 없을때 2단계 인증 코드 대용으로 사용할 수 있습니다! 이 코드들을 비밀번호처럼 안전하게 보관하세요."
|
||||
},
|
||||
"settings.account.two-factor.backup.single-use": {
|
||||
"message": "백업 코드는 한번만 사용 할 수 있습니다."
|
||||
"message": "복구 코드는 한번만 사용 할 수 있습니다."
|
||||
},
|
||||
"settings.account.two-factor.error.incorrect-code": {
|
||||
"message": "입력한 코드가 맞지 않습니다!"
|
||||
@@ -3141,7 +3141,7 @@
|
||||
"message": "Modrinth Plus"
|
||||
},
|
||||
"settings.billing.charges.product.pyro": {
|
||||
"message": "Modrinth Hosting"
|
||||
"message": "Modrinth 호스팅"
|
||||
},
|
||||
"settings.billing.charges.title": {
|
||||
"message": "과거 결제 내역"
|
||||
@@ -3174,7 +3174,7 @@
|
||||
"message": "프로필에 Modrinth+ 배지"
|
||||
},
|
||||
"settings.billing.midas.benefits.support": {
|
||||
"message": "Modrith와 크리에이터 후원"
|
||||
"message": "Modrith 와 크리에이터 후원"
|
||||
},
|
||||
"settings.billing.midas.benefits.title": {
|
||||
"message": "장점"
|
||||
@@ -3297,7 +3297,7 @@
|
||||
"message": "{gb} GB 스왑 메모리"
|
||||
},
|
||||
"settings.billing.pyro_subscription.description": {
|
||||
"message": "당신의 Modrinth Hosting 구독을 관리하세요."
|
||||
"message": "당신의 Modrinth 호스팅 구독을 관리하세요."
|
||||
},
|
||||
"settings.billing.pyro_subscription.title": {
|
||||
"message": "Modrinth Hosting 구독"
|
||||
@@ -3411,10 +3411,10 @@
|
||||
"message": "새 탭에서 외부 링크 열기"
|
||||
},
|
||||
"settings.display.sidebar.hide-app-promos.description": {
|
||||
"message": "기본 네비게이선에서 \"Modrinth App 받기\" 버튼을 숨깁니다. Modrinth App 페이지는 여전히 시작 페이지 또는 하단에서 찾을 수 있습니다."
|
||||
"message": "기본 네비게이선에서 \"Modrinth 앱 받기\" 버튼을 숨깁니다. Modrinth 앱 페이지는 여전히 시작 페이지 또는 하단에서 찾을 수 있습니다."
|
||||
},
|
||||
"settings.display.sidebar.hide-app-promos.title": {
|
||||
"message": "Modrinth App 프로모션 숨기기"
|
||||
"message": "Modrinth 앱 프로모션 숨기기"
|
||||
},
|
||||
"settings.display.sidebar.left-aligned-content-sidebar.title": {
|
||||
"message": "콘텐츠 페이지에서 사이드바를 왼쪽으로 정렬"
|
||||
@@ -3429,7 +3429,7 @@
|
||||
"message": "검색 페이지에서 필터 사이드바를 오른쪽으로 정렬"
|
||||
},
|
||||
"settings.display.theme.description": {
|
||||
"message": "이 디바이스에서 선호하는 Modrinth의 색상 테마를 선택합니다."
|
||||
"message": "이 디바이스에서 선호하는 Modrinth 의 색상 테마를 선택합니다."
|
||||
},
|
||||
"settings.display.theme.title": {
|
||||
"message": "색상 테마"
|
||||
@@ -3462,7 +3462,7 @@
|
||||
"message": "이 토큰 삭제"
|
||||
},
|
||||
"settings.pats.modal.delete.description": {
|
||||
"message": "토큰이 영구적으로 삭제됩니다."
|
||||
"message": "토큰이 영구적으로 삭제됩니다 (영원히)."
|
||||
},
|
||||
"settings.pats.modal.delete.title": {
|
||||
"message": "정말로 이 토큰을 삭제할까요?"
|
||||
@@ -3489,13 +3489,13 @@
|
||||
"message": "사용된 적 없음"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "모든 사람에게 당신에 대해 소개하는 간단한 설명입니다."
|
||||
"message": "모든 사람에게 당신을 소개하는 간단한 설명입니다."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "자기소개"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "당신의 프로필 정보는 Modrinth나 <docs-link>Modrinth API</docs-link>를 통해 공개될 수 있습니다."
|
||||
"message": "당신의 프로필 정보는 Modrinth 나 <docs-link>Modrinth API</docs-link>를 통해 공개될 수 있습니다."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "프로필 설정"
|
||||
|
||||
@@ -554,6 +554,9 @@
|
||||
"dashboard.affiliate-links.create.button": {
|
||||
"message": "Utwórz link afiliacyjny"
|
||||
},
|
||||
"dashboard.affiliate-links.empty.no-codes": {
|
||||
"message": "Nie znaleziono żadnych kodów afiliacyjnych."
|
||||
},
|
||||
"dashboard.affiliate-links.error.title": {
|
||||
"message": "Błąd podczas ładowania linków afiliacyjnych"
|
||||
},
|
||||
@@ -572,6 +575,15 @@
|
||||
"dashboard.affiliate-links.search": {
|
||||
"message": "Szukaj linku afiliacyjnego..."
|
||||
},
|
||||
"dashboard.analytics.from-projects": {
|
||||
"message": "z {count} {count, plural, one {projektu} other {projektów}}"
|
||||
},
|
||||
"dashboard.analytics.total-downloads": {
|
||||
"message": "Wszystkie pobrania"
|
||||
},
|
||||
"dashboard.analytics.total-followers": {
|
||||
"message": "Wszyscy obserwujący"
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "Stwórz nową"
|
||||
},
|
||||
@@ -596,6 +608,18 @@
|
||||
"dashboard.collections.long-title": {
|
||||
"message": "Twoje kolekcje"
|
||||
},
|
||||
"dashboard.collections.placeholder.search": {
|
||||
"message": "Szukaj kolekcji..."
|
||||
},
|
||||
"dashboard.collections.sort.name-ascending": {
|
||||
"message": "Nazwa (A-Z)"
|
||||
},
|
||||
"dashboard.collections.sort.recently-created": {
|
||||
"message": "Ostatnio utworzone"
|
||||
},
|
||||
"dashboard.collections.sort.recently-updated": {
|
||||
"message": "Ostatnio zaktualizowane"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.download-button": {
|
||||
"message": "Pobierz {formType}"
|
||||
},
|
||||
@@ -678,7 +702,7 @@
|
||||
"message": "Szukaj kraju..."
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.method-selection.error-text": {
|
||||
"message": "Nie udało się odczytać dostępnych metod płatniczych. Spróbuj ponownie później."
|
||||
"message": "Nie udało się znaleźć dostępnych metod płatniczych. Spróbuj ponownie później."
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.method-selection.error-title": {
|
||||
"message": "Nie udało się odczytać dostępnych metod płatniczych"
|
||||
@@ -848,6 +872,168 @@
|
||||
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
|
||||
"message": "Przekroczono limit wypłat (<b>{withdrawLimit}</b>). Musisz wypełnić formularz podatkowy, by wypłacić więcej."
|
||||
},
|
||||
"dashboard.head-title": {
|
||||
"message": "Kokpit"
|
||||
},
|
||||
"dashboard.notifications.button.mark-all-as-read": {
|
||||
"message": "Oznacz wszystkie jako przeczytane"
|
||||
},
|
||||
"dashboard.notifications.button.view-history": {
|
||||
"message": "Pokaż historię"
|
||||
},
|
||||
"dashboard.notifications.empty.no-unread": {
|
||||
"message": "Nie masz żadnych nieprzeczytanych powiadomień."
|
||||
},
|
||||
"dashboard.notifications.error.loading": {
|
||||
"message": "Wystąpił błąd podczas ładowania powiadomień:"
|
||||
},
|
||||
"dashboard.notifications.history.label": {
|
||||
"message": "Historia"
|
||||
},
|
||||
"dashboard.notifications.history.title": {
|
||||
"message": "Historia powiadomień"
|
||||
},
|
||||
"dashboard.notifications.link.see-all": {
|
||||
"message": "Zobacz wszystkie"
|
||||
},
|
||||
"dashboard.notifications.link.view-history": {
|
||||
"message": "Pokaż historię powiadomień"
|
||||
},
|
||||
"dashboard.notifications.link.view-more": {
|
||||
"message": "Pokaż jeszcze {extraNotifs} {extraNotifs, plural, one {powiadomienie} few {powiadomienia} other {powiadomień}}"
|
||||
},
|
||||
"dashboard.notifications.loading": {
|
||||
"message": "Ładowanie powiadomień..."
|
||||
},
|
||||
"dashboard.organizations.button.create": {
|
||||
"message": "Utwórz organizację"
|
||||
},
|
||||
"dashboard.organizations.empty.cta": {
|
||||
"message": "Utwórz organizację!"
|
||||
},
|
||||
"dashboard.organizations.error.fetch": {
|
||||
"message": "Nie udało się znaleźć organizacji"
|
||||
},
|
||||
"dashboard.organizations.member-count": {
|
||||
"message": "{count} {count, plural, one {członek} other {członków}}"
|
||||
},
|
||||
"dashboard.organizations.title": {
|
||||
"message": "Organizacje"
|
||||
},
|
||||
"dashboard.projects.bulk-edit-hint": {
|
||||
"message": "Możesz edytować kilka projektów naraz, zaznaczając je poniżej."
|
||||
},
|
||||
"dashboard.projects.bulk-edit.server-disabled": {
|
||||
"message": "Projekty serwerów nie wspierają grupowej edycji"
|
||||
},
|
||||
"dashboard.projects.empty": {
|
||||
"message": "Nie masz jeszcze żadnych projektów. Wciśnij zielony przycisk powyżej, aby rozpocząć."
|
||||
},
|
||||
"dashboard.projects.head-title": {
|
||||
"message": "Projekty"
|
||||
},
|
||||
"dashboard.projects.links.and-more": {
|
||||
"message": "i {count} {count, plural, one {inny} few {inne} other {innych}}..."
|
||||
},
|
||||
"dashboard.projects.links.button.clear-link": {
|
||||
"message": "Wyczyść link"
|
||||
},
|
||||
"dashboard.projects.links.button.edit": {
|
||||
"message": "Edytuj linki"
|
||||
},
|
||||
"dashboard.projects.links.changes-applied": {
|
||||
"message": "Zmiany zostaną zastosowane dla <strong>{count}</strong> {count, plural, one {projektu} other {projektów}}."
|
||||
},
|
||||
"dashboard.projects.links.description": {
|
||||
"message": "Linki podane poniżej nadpiszą te w każdym z wybranych projektów. Puste linki będą ignorowane. Możesz usunąć link ze wszystkich wybranych projektów przyciskiem kosza na śmieci."
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.description": {
|
||||
"message": "Zaproszenie do Twojego serwera Discord."
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.label": {
|
||||
"message": "Zaproszenie Discord"
|
||||
},
|
||||
"dashboard.projects.links.issue-tracker.description": {
|
||||
"message": "Miejsce, gdzie użytkownicy mogą zgłaszać problemy i uwagi do Twojego projektu."
|
||||
},
|
||||
"dashboard.projects.links.issue-tracker.label": {
|
||||
"message": "Śledzenie problemów"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.cleared": {
|
||||
"message": "Istniejący link zostanie usunięty"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-discord-url": {
|
||||
"message": "Podaj prawidłowy URL zaproszenia Discord"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-url": {
|
||||
"message": "Podaj prawidłowy URL"
|
||||
},
|
||||
"dashboard.projects.links.show-all-projects": {
|
||||
"message": "Pokaż wszystkie projekty"
|
||||
},
|
||||
"dashboard.projects.links.source-code.description": {
|
||||
"message": "Strona/repozytorium zawierające kod źródłowy Twojego projektu"
|
||||
},
|
||||
"dashboard.projects.links.source-code.label": {
|
||||
"message": "Kod źródłowy"
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.description": {
|
||||
"message": "Strona zawierające informację, dokumentację i pomoc projektu."
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.label": {
|
||||
"message": "Strona wiki"
|
||||
},
|
||||
"dashboard.projects.notification.bulk-edit-success": {
|
||||
"message": "Masowo edytowano linki wybranych projektów."
|
||||
},
|
||||
"dashboard.projects.project.icon-alt": {
|
||||
"message": "Ikona {title}"
|
||||
},
|
||||
"dashboard.projects.project.moderator-message-aria": {
|
||||
"message": "Projekt ma wiadomość od moderatorów. Otwórz projekt, by zobaczyć więcej."
|
||||
},
|
||||
"dashboard.projects.project.review-environment-metadata": {
|
||||
"message": "Sprawdź metadane środowiska"
|
||||
},
|
||||
"dashboard.projects.sort.ascending": {
|
||||
"message": "Rosnąco"
|
||||
},
|
||||
"dashboard.projects.sort.descending": {
|
||||
"message": "Malejąco"
|
||||
},
|
||||
"dashboard.projects.sort.option.name": {
|
||||
"message": "Nazwa"
|
||||
},
|
||||
"dashboard.projects.sort.option.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"dashboard.projects.sort.option.type": {
|
||||
"message": "Rodzaj"
|
||||
},
|
||||
"dashboard.projects.table.icon": {
|
||||
"message": "Ikona"
|
||||
},
|
||||
"dashboard.projects.table.id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"dashboard.projects.table.name": {
|
||||
"message": "Nazwa"
|
||||
},
|
||||
"dashboard.projects.table.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"dashboard.projects.table.type": {
|
||||
"message": "Rodzaj"
|
||||
},
|
||||
"dashboard.report.title": {
|
||||
"message": "Zgłoś {id}"
|
||||
},
|
||||
"dashboard.reports.active-title": {
|
||||
"message": "Aktywne zgłoszenia"
|
||||
},
|
||||
"dashboard.reports.title": {
|
||||
"message": "Zgłoszenia"
|
||||
},
|
||||
"dashboard.revenue.available-now": {
|
||||
"message": "Dostępne teraz"
|
||||
},
|
||||
@@ -884,6 +1070,9 @@
|
||||
"dashboard.revenue.transactions.btn.download-csv": {
|
||||
"message": "Pobierz jako CSV"
|
||||
},
|
||||
"dashboard.revenue.transactions.head-title": {
|
||||
"message": "Historia transakcji"
|
||||
},
|
||||
"dashboard.revenue.transactions.header": {
|
||||
"message": "Transakcje"
|
||||
},
|
||||
@@ -893,9 +1082,18 @@
|
||||
"dashboard.revenue.transactions.none.desc": {
|
||||
"message": "Tutaj pojawią się otrzymane fundusze i wypłaty."
|
||||
},
|
||||
"dashboard.revenue.transactions.period.last-month": {
|
||||
"message": "W zeszłym miesiącu"
|
||||
},
|
||||
"dashboard.revenue.transactions.period.this-month": {
|
||||
"message": "W tym miesiącu"
|
||||
},
|
||||
"dashboard.revenue.transactions.see-all": {
|
||||
"message": "Zobacz wszystkie"
|
||||
},
|
||||
"dashboard.revenue.transactions.year.all": {
|
||||
"message": "Wszystkie lata"
|
||||
},
|
||||
"dashboard.revenue.withdraw.blocked-tin-mismatch": {
|
||||
"message": "Twoje wypłaty są tymczasowo zablokowane, ponieważ Twój TIN lub SSN nie pasował do zapisów IRS. Skontaktuj się z obsługą, by resetować i ponownie wysłać Twój formularz podatkowy."
|
||||
},
|
||||
@@ -908,6 +1106,33 @@
|
||||
"dashboard.revenue.withdraw.header": {
|
||||
"message": "Wypłata"
|
||||
},
|
||||
"dashboard.sidebar.label.activeReports": {
|
||||
"message": "Aktywne zgłoszenia"
|
||||
},
|
||||
"dashboard.sidebar.label.analytics": {
|
||||
"message": "Analityka"
|
||||
},
|
||||
"dashboard.sidebar.label.creators": {
|
||||
"message": "Twórcy"
|
||||
},
|
||||
"dashboard.sidebar.label.dashboard": {
|
||||
"message": "Kokpit"
|
||||
},
|
||||
"dashboard.sidebar.label.notifications": {
|
||||
"message": "Powiadomienia"
|
||||
},
|
||||
"dashboard.sidebar.label.organizations": {
|
||||
"message": "Organizacje"
|
||||
},
|
||||
"dashboard.sidebar.label.overview": {
|
||||
"message": "Przegląd"
|
||||
},
|
||||
"dashboard.sidebar.label.projects": {
|
||||
"message": "Projekty"
|
||||
},
|
||||
"dashboard.sidebar.label.revenue": {
|
||||
"message": "Przychód"
|
||||
},
|
||||
"dashboard.withdraw.completion.account": {
|
||||
"message": "Konto"
|
||||
},
|
||||
@@ -1311,7 +1536,7 @@
|
||||
"message": "Odkrywaj modyfikacje"
|
||||
},
|
||||
"landing.button.go-to-dashboard": {
|
||||
"message": "Przejdź do panelu głównego"
|
||||
"message": "Przejdź do kokpitu"
|
||||
},
|
||||
"landing.creator.feature.constantly-evolving.description": {
|
||||
"message": "Uzyskaj najlepsze możliwe doświadczenie w modowaniu dzięki stałym aktualizacjom od zespołu Modrinth"
|
||||
@@ -2304,7 +2529,7 @@
|
||||
"message": "Sprawdź ustawienia środowiska"
|
||||
},
|
||||
"project.environment.migration.title": {
|
||||
"message": "Proszę sprawdzić metadane środowiska"
|
||||
"message": "Sprawdź metadane środowiska"
|
||||
},
|
||||
"project.error.loading": {
|
||||
"message": "Błąd podczas ładowania danych projektu{message}"
|
||||
@@ -3158,6 +3383,9 @@
|
||||
"settings.billing.interval.quarter": {
|
||||
"message": "kwartał"
|
||||
},
|
||||
"settings.billing.interval.quarterly.adjective": {
|
||||
"message": "kwartalną"
|
||||
},
|
||||
"settings.billing.interval.year": {
|
||||
"message": "rok"
|
||||
},
|
||||
@@ -3272,6 +3500,9 @@
|
||||
"settings.billing.pyro.resubscribe.error.title": {
|
||||
"message": "Błąd ponawiania subskrypcji"
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.request-submitted.text": {
|
||||
"message": "Jeżeli serwer jest aktualnie zawieszony, jego aktywacja może zająć od 10 do 15 minut."
|
||||
},
|
||||
"settings.billing.pyro.resubscribe.request-submitted.title": {
|
||||
"message": "Wysłano wniosek o ponowienie subskrypcji"
|
||||
},
|
||||
|
||||
@@ -554,6 +554,9 @@
|
||||
"dashboard.affiliate-links.create.button": {
|
||||
"message": "Criar link de afiliado"
|
||||
},
|
||||
"dashboard.affiliate-links.empty.no-codes": {
|
||||
"message": "Nenhum código de afiliado encontrado."
|
||||
},
|
||||
"dashboard.affiliate-links.error.title": {
|
||||
"message": "Erro ao carregar links de afiliado"
|
||||
},
|
||||
@@ -572,6 +575,15 @@
|
||||
"dashboard.affiliate-links.search": {
|
||||
"message": "Procurar links de afiliado..."
|
||||
},
|
||||
"dashboard.analytics.from-projects": {
|
||||
"message": "de {count} {count, plural, one {projeto} other {projetos}}"
|
||||
},
|
||||
"dashboard.analytics.total-downloads": {
|
||||
"message": "Downloads totais"
|
||||
},
|
||||
"dashboard.analytics.total-followers": {
|
||||
"message": "Seguidores totais"
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "Criar nova"
|
||||
},
|
||||
@@ -596,6 +608,18 @@
|
||||
"dashboard.collections.long-title": {
|
||||
"message": "Suas coleções"
|
||||
},
|
||||
"dashboard.collections.placeholder.search": {
|
||||
"message": "Buscar coleções..."
|
||||
},
|
||||
"dashboard.collections.sort.name-ascending": {
|
||||
"message": "Nome (A-Z)"
|
||||
},
|
||||
"dashboard.collections.sort.recently-created": {
|
||||
"message": "Criado recentemente"
|
||||
},
|
||||
"dashboard.collections.sort.recently-updated": {
|
||||
"message": "Atualizado recentemente"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.download-button": {
|
||||
"message": "Baixar {formType}"
|
||||
},
|
||||
@@ -848,6 +872,168 @@
|
||||
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
|
||||
"message": "Você esgotou seu limite de retirada <b>{withdrawLimit}</b>. Você deve completar o formulário fiscal para retirar mais."
|
||||
},
|
||||
"dashboard.head-title": {
|
||||
"message": "Painel de controle"
|
||||
},
|
||||
"dashboard.notifications.button.mark-all-as-read": {
|
||||
"message": "Marcar tudo como lido"
|
||||
},
|
||||
"dashboard.notifications.button.view-history": {
|
||||
"message": "Ver histórico"
|
||||
},
|
||||
"dashboard.notifications.empty.no-unread": {
|
||||
"message": "Você não tem nenhuma notificação não lida."
|
||||
},
|
||||
"dashboard.notifications.error.loading": {
|
||||
"message": "Erro ao carregar as notificações:"
|
||||
},
|
||||
"dashboard.notifications.history.label": {
|
||||
"message": "Histórico"
|
||||
},
|
||||
"dashboard.notifications.history.title": {
|
||||
"message": "Histórico de notificações"
|
||||
},
|
||||
"dashboard.notifications.link.see-all": {
|
||||
"message": "Ver todas"
|
||||
},
|
||||
"dashboard.notifications.link.view-history": {
|
||||
"message": "Ver histórico de notificações"
|
||||
},
|
||||
"dashboard.notifications.link.view-more": {
|
||||
"message": "Ver mais {extraNotifs} {extraNotifs, plural, one {notificação} other {notificações}}"
|
||||
},
|
||||
"dashboard.notifications.loading": {
|
||||
"message": "Carregando notificações..."
|
||||
},
|
||||
"dashboard.organizations.button.create": {
|
||||
"message": "Criar organização"
|
||||
},
|
||||
"dashboard.organizations.empty.cta": {
|
||||
"message": "Crie uma organização!"
|
||||
},
|
||||
"dashboard.organizations.error.fetch": {
|
||||
"message": "Falha ao buscar organizações"
|
||||
},
|
||||
"dashboard.organizations.member-count": {
|
||||
"message": "{count} {count, plural, one {membro} other {membros}}"
|
||||
},
|
||||
"dashboard.organizations.title": {
|
||||
"message": "Organizações"
|
||||
},
|
||||
"dashboard.projects.bulk-edit-hint": {
|
||||
"message": "Você pode editar vários projetos em simultâneo, selecionando-os abaixo."
|
||||
},
|
||||
"dashboard.projects.bulk-edit.server-disabled": {
|
||||
"message": "Os projetos de servidor não suportam edição em massa"
|
||||
},
|
||||
"dashboard.projects.empty": {
|
||||
"message": "Você ainda não tem nenhum projeto. Clique no botão verde acima para começar."
|
||||
},
|
||||
"dashboard.projects.head-title": {
|
||||
"message": "Projetos"
|
||||
},
|
||||
"dashboard.projects.links.and-more": {
|
||||
"message": "e {count} mais..."
|
||||
},
|
||||
"dashboard.projects.links.button.clear-link": {
|
||||
"message": "Apagar link"
|
||||
},
|
||||
"dashboard.projects.links.button.edit": {
|
||||
"message": "Editar links"
|
||||
},
|
||||
"dashboard.projects.links.changes-applied": {
|
||||
"message": "As alterações serão aplicadas a <strong>{count}</strong> {count, plural, one {projeto} other {projetos}}."
|
||||
},
|
||||
"dashboard.projects.links.description": {
|
||||
"message": "Quaisquer links que você especificar abaixo serão sobrescritos em todos os projetos selecionados. Os links que você deixar em branco serão ignorados. Você pode remover um link de todos os projetos selecionados usando o botão de lixeira."
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.description": {
|
||||
"message": "Um link de convite para o seu servidor do Discord."
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.label": {
|
||||
"message": "Convite do Discord"
|
||||
},
|
||||
"dashboard.projects.links.issue-tracker.description": {
|
||||
"message": "Um espaço para os usuários relatarem erros, problemas e dúvidas sobre o seu projeto."
|
||||
},
|
||||
"dashboard.projects.links.issue-tracker.label": {
|
||||
"message": "Rastreador de problemas"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.cleared": {
|
||||
"message": "O link existente será apagado"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-discord-url": {
|
||||
"message": "Insira um URL de convite do Discord válido"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-url": {
|
||||
"message": "Insira um URL válido"
|
||||
},
|
||||
"dashboard.projects.links.show-all-projects": {
|
||||
"message": "Mostrar todos os projetos"
|
||||
},
|
||||
"dashboard.projects.links.source-code.description": {
|
||||
"message": "Uma página/repositório contendo o código-fonte do seu projeto"
|
||||
},
|
||||
"dashboard.projects.links.source-code.label": {
|
||||
"message": "Código-fonte"
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.description": {
|
||||
"message": "Uma pagina contendo informações, documentação, e ajuda para o projeto."
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.label": {
|
||||
"message": "Página da Wiki"
|
||||
},
|
||||
"dashboard.projects.notification.bulk-edit-success": {
|
||||
"message": "Links dos projetos selecionados editados em massa."
|
||||
},
|
||||
"dashboard.projects.project.icon-alt": {
|
||||
"message": "Ícone para {title}"
|
||||
},
|
||||
"dashboard.projects.project.moderator-message-aria": {
|
||||
"message": "O projeto tem uma mensagem dos moderadores. Veja o projeto para saber mais."
|
||||
},
|
||||
"dashboard.projects.project.review-environment-metadata": {
|
||||
"message": "Por favor, revise os metadados do ambiente"
|
||||
},
|
||||
"dashboard.projects.sort.ascending": {
|
||||
"message": "Crescente"
|
||||
},
|
||||
"dashboard.projects.sort.descending": {
|
||||
"message": "Decrescente"
|
||||
},
|
||||
"dashboard.projects.sort.option.name": {
|
||||
"message": "Nome"
|
||||
},
|
||||
"dashboard.projects.sort.option.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"dashboard.projects.sort.option.type": {
|
||||
"message": "Tipo"
|
||||
},
|
||||
"dashboard.projects.table.icon": {
|
||||
"message": "Ícone"
|
||||
},
|
||||
"dashboard.projects.table.id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"dashboard.projects.table.name": {
|
||||
"message": "Nome"
|
||||
},
|
||||
"dashboard.projects.table.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"dashboard.projects.table.type": {
|
||||
"message": "Tipo"
|
||||
},
|
||||
"dashboard.report.title": {
|
||||
"message": "Denunciar {id}"
|
||||
},
|
||||
"dashboard.reports.active-title": {
|
||||
"message": "Relatórios ativos"
|
||||
},
|
||||
"dashboard.reports.title": {
|
||||
"message": "Relatórios"
|
||||
},
|
||||
"dashboard.revenue.available-now": {
|
||||
"message": "Disponível agora"
|
||||
},
|
||||
@@ -884,6 +1070,9 @@
|
||||
"dashboard.revenue.transactions.btn.download-csv": {
|
||||
"message": "Baixar como CSV"
|
||||
},
|
||||
"dashboard.revenue.transactions.head-title": {
|
||||
"message": "Histórico de transações"
|
||||
},
|
||||
"dashboard.revenue.transactions.header": {
|
||||
"message": "Transações"
|
||||
},
|
||||
@@ -893,9 +1082,18 @@
|
||||
"dashboard.revenue.transactions.none.desc": {
|
||||
"message": "Seus pagamentos e retiradas aparecerão aqui."
|
||||
},
|
||||
"dashboard.revenue.transactions.period.last-month": {
|
||||
"message": "Mês passado"
|
||||
},
|
||||
"dashboard.revenue.transactions.period.this-month": {
|
||||
"message": "Este mês"
|
||||
},
|
||||
"dashboard.revenue.transactions.see-all": {
|
||||
"message": "Ver todas"
|
||||
},
|
||||
"dashboard.revenue.transactions.year.all": {
|
||||
"message": "Todos os anos"
|
||||
},
|
||||
"dashboard.revenue.withdraw.blocked-tin-mismatch": {
|
||||
"message": "Suas restiradas estão temporariamente bloqueadas devido a seu TIN ou SSN não coincidirem com seus registros do IRS. Por favor, contate o suporte para reestabelecer e reenviar seu formulário fiscal."
|
||||
},
|
||||
@@ -908,6 +1106,33 @@
|
||||
"dashboard.revenue.withdraw.header": {
|
||||
"message": "Retirar"
|
||||
},
|
||||
"dashboard.sidebar.label.activeReports": {
|
||||
"message": "Relatórios ativos"
|
||||
},
|
||||
"dashboard.sidebar.label.analytics": {
|
||||
"message": "Estatísticas"
|
||||
},
|
||||
"dashboard.sidebar.label.creators": {
|
||||
"message": "Criadores"
|
||||
},
|
||||
"dashboard.sidebar.label.dashboard": {
|
||||
"message": "Painel de controle"
|
||||
},
|
||||
"dashboard.sidebar.label.notifications": {
|
||||
"message": "Notificações"
|
||||
},
|
||||
"dashboard.sidebar.label.organizations": {
|
||||
"message": "Organizações"
|
||||
},
|
||||
"dashboard.sidebar.label.overview": {
|
||||
"message": "Visão geral"
|
||||
},
|
||||
"dashboard.sidebar.label.projects": {
|
||||
"message": "Projetos"
|
||||
},
|
||||
"dashboard.sidebar.label.revenue": {
|
||||
"message": "Faturamento"
|
||||
},
|
||||
"dashboard.withdraw.completion.account": {
|
||||
"message": "Conta"
|
||||
},
|
||||
|
||||
@@ -596,6 +596,12 @@
|
||||
"dashboard.collections.long-title": {
|
||||
"message": "As tuas coleções"
|
||||
},
|
||||
"dashboard.collections.sort.recently-created": {
|
||||
"message": "Criado recentemente"
|
||||
},
|
||||
"dashboard.collections.sort.recently-updated": {
|
||||
"message": "Atualizado recentemente"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.download-button": {
|
||||
"message": "Transferir {formType}"
|
||||
},
|
||||
@@ -848,6 +854,69 @@
|
||||
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
|
||||
"message": "Já utilizou o seu limite de levantamento de <b>{withdrawLimit}</b>. Deve preencher um formulário fiscal para levantar mais."
|
||||
},
|
||||
"dashboard.notifications.button.mark-all-as-read": {
|
||||
"message": "Marcar tudo como lido"
|
||||
},
|
||||
"dashboard.notifications.button.view-history": {
|
||||
"message": "Ver histórico"
|
||||
},
|
||||
"dashboard.notifications.history.label": {
|
||||
"message": "Histórico"
|
||||
},
|
||||
"dashboard.notifications.history.title": {
|
||||
"message": "Histórico de notificações"
|
||||
},
|
||||
"dashboard.notifications.link.see-all": {
|
||||
"message": "Ver tudo"
|
||||
},
|
||||
"dashboard.notifications.link.view-history": {
|
||||
"message": "Ver histórico de notificações"
|
||||
},
|
||||
"dashboard.notifications.loading": {
|
||||
"message": "Carregando notificações..."
|
||||
},
|
||||
"dashboard.projects.head-title": {
|
||||
"message": "Projetos"
|
||||
},
|
||||
"dashboard.projects.links.button.clear-link": {
|
||||
"message": "Limpar link"
|
||||
},
|
||||
"dashboard.projects.links.button.edit": {
|
||||
"message": "Editar links"
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.label": {
|
||||
"message": "Convite do Discord"
|
||||
},
|
||||
"dashboard.projects.links.source-code.label": {
|
||||
"message": "Código-fonte"
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.label": {
|
||||
"message": "Página da Wiki"
|
||||
},
|
||||
"dashboard.projects.sort.option.name": {
|
||||
"message": "Nome"
|
||||
},
|
||||
"dashboard.projects.sort.option.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"dashboard.projects.sort.option.type": {
|
||||
"message": "Tipo"
|
||||
},
|
||||
"dashboard.projects.table.icon": {
|
||||
"message": "Ícone"
|
||||
},
|
||||
"dashboard.projects.table.id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"dashboard.projects.table.name": {
|
||||
"message": "Nome"
|
||||
},
|
||||
"dashboard.projects.table.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"dashboard.projects.table.type": {
|
||||
"message": "Tipo"
|
||||
},
|
||||
"dashboard.revenue.available-now": {
|
||||
"message": "Disponível agora"
|
||||
},
|
||||
@@ -893,6 +962,12 @@
|
||||
"dashboard.revenue.transactions.none.desc": {
|
||||
"message": "Os teus pagamentos e levantamentos aparecerão aqui."
|
||||
},
|
||||
"dashboard.revenue.transactions.period.last-month": {
|
||||
"message": "Mês passado"
|
||||
},
|
||||
"dashboard.revenue.transactions.period.this-month": {
|
||||
"message": "Esse mês"
|
||||
},
|
||||
"dashboard.revenue.transactions.see-all": {
|
||||
"message": "Ver todas"
|
||||
},
|
||||
@@ -908,6 +983,12 @@
|
||||
"dashboard.revenue.withdraw.header": {
|
||||
"message": "Levantar"
|
||||
},
|
||||
"dashboard.sidebar.label.creators": {
|
||||
"message": "Criadores"
|
||||
},
|
||||
"dashboard.sidebar.label.notifications": {
|
||||
"message": "Notificações"
|
||||
},
|
||||
"dashboard.withdraw.completion.account": {
|
||||
"message": "Conta"
|
||||
},
|
||||
|
||||
@@ -393,7 +393,7 @@
|
||||
"message": "В коллекции пока нет проектов"
|
||||
},
|
||||
"collection.label.projects-count": {
|
||||
"message": "{count, plural, =0 {Нет проектов} other {<stat>{count}</stat> {type}}}"
|
||||
"message": "{count, plural, =0 {Нет проектов} other {<stat>{count}</stat> {type}}}"
|
||||
},
|
||||
"collection.label.updated-at": {
|
||||
"message": "Обновлена {ago}"
|
||||
@@ -411,7 +411,7 @@
|
||||
"message": "Дополнительные файлы предназначены для вспомогательных ресурсов, например исходного кода, но не для альтернативных версий или вариантов."
|
||||
},
|
||||
"create.collection.collection-info": {
|
||||
"message": "Новая коллекция будет публичной {count, plural, =0 {без проектов} one {с # проектом} other {с # проектами}}."
|
||||
"message": "Будет создана публичная коллекция {count, plural, =0 {без проектов} one {с # проектом} other {с # проектами}}."
|
||||
},
|
||||
"create.collection.create-collection": {
|
||||
"message": "Создать коллекцию"
|
||||
@@ -507,7 +507,7 @@
|
||||
"message": "Введите название проекта..."
|
||||
},
|
||||
"create.project.owner-description": {
|
||||
"message": "Укажите в качестве владельца проекта себя или организацию, членом которой вы являетесь."
|
||||
"message": "Укажите себя или организацию, в которой состоите."
|
||||
},
|
||||
"create.project.owner-label": {
|
||||
"message": "Владелец"
|
||||
@@ -576,7 +576,7 @@
|
||||
"message": "Создать новую"
|
||||
},
|
||||
"dashboard.collections.empty.get-started-hint": {
|
||||
"message": "Создайте свою первую коллекцию, чтобы начать работу!"
|
||||
"message": "Создайте свою первую коллекцию!"
|
||||
},
|
||||
"dashboard.collections.empty.no-collections": {
|
||||
"message": "Коллекций пока нет"
|
||||
@@ -714,7 +714,7 @@
|
||||
"message": "Номер национального удостоверения"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.muralpay-details.document-number-national-id-placeholder": {
|
||||
"message": "Введите номер национального удостоверения личности"
|
||||
"message": "Введите номер национального удостоверения"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.muralpay-details.document-number-passport": {
|
||||
"message": "Номер загранпаспорта"
|
||||
@@ -858,7 +858,7 @@
|
||||
"message": "Сумма может измениться, прежде чем станет доступна."
|
||||
},
|
||||
"dashboard.revenue.estimated-tooltip.msg2": {
|
||||
"message": "Нажмите, чтобы узнать, как Modrinth рассчитывает доход."
|
||||
"message": "Нажмите, чтобы узнать, как Modrinth рассчитывает доход."
|
||||
},
|
||||
"dashboard.revenue.estimated-with-date": {
|
||||
"message": "Ожидается {date}"
|
||||
@@ -893,9 +893,18 @@
|
||||
"dashboard.revenue.transactions.none.desc": {
|
||||
"message": "Выплаты и выводы средств появятся здесь."
|
||||
},
|
||||
"dashboard.revenue.transactions.period.last-month": {
|
||||
"message": "В прошлом месяце"
|
||||
},
|
||||
"dashboard.revenue.transactions.period.this-month": {
|
||||
"message": "В этом месяце"
|
||||
},
|
||||
"dashboard.revenue.transactions.see-all": {
|
||||
"message": "Посмотреть все"
|
||||
},
|
||||
"dashboard.revenue.transactions.year.all": {
|
||||
"message": "Все годы"
|
||||
},
|
||||
"dashboard.revenue.withdraw.blocked-tin-mismatch": {
|
||||
"message": "Вывод средств временно заблокирован: указанный TIN или SSN не совпал с данными IRS. Свяжитесь с поддержкой для повторной отправки формы."
|
||||
},
|
||||
@@ -1032,10 +1041,10 @@
|
||||
"message": "Контент недоступен по юридическим причинам"
|
||||
},
|
||||
"error.generic.default.list_item.1": {
|
||||
"message": "Проверьте, работает ли Modrinth, на нашей <status-link>странице статуса</status-link>."
|
||||
"message": "Проверьте доступность Modrinth на <status-link>странице статуса</status-link>."
|
||||
},
|
||||
"error.generic.default.list_item.2": {
|
||||
"message": "Если ошибка не исчезнет, вы можете уведомить о ней Modrinth Team в нашем <discord-link>сервере Discord</discord-link>."
|
||||
"message": "Если проблема не исчезает, сообщите о ней команде Modrinth в <discord-link>Discord</discord-link>."
|
||||
},
|
||||
"error.generic.default.list_title": {
|
||||
"message": "Повторите попытку через несколько минут."
|
||||
@@ -1314,37 +1323,37 @@
|
||||
"message": "Перейти в панель управления"
|
||||
},
|
||||
"landing.creator.feature.constantly-evolving.description": {
|
||||
"message": "Получайте лучший опыт в создании модов с постоянными обновлениями от команды Modrinth"
|
||||
"message": "Получайте лучший опыт моддинга благодаря постоянным обновлениям от команды Modrinth"
|
||||
},
|
||||
"landing.creator.feature.constantly-evolving.title": {
|
||||
"message": "Постоянное развитие"
|
||||
},
|
||||
"landing.creator.feature.data-statistics.description": {
|
||||
"message": "Получайте детальные отчёты о просмотрах, скачиваниях и доходах"
|
||||
"message": "Получайте подробные отчёты о просмотрах, скачиваниях и доходах"
|
||||
},
|
||||
"landing.creator.feature.data-statistics.title": {
|
||||
"message": "Данные и статистика"
|
||||
},
|
||||
"landing.creator.feature.discovery.description": {
|
||||
"message": "Получайте внимание тысяч пользователей через поиск, главную страницу, сервер Discord и другие способы, которые появятся в будущем!"
|
||||
"message": "Получайте внимание тысяч пользователей через поиск, главную страницу, сервер Discord и другие способы в будущем!"
|
||||
},
|
||||
"landing.creator.feature.discovery.title": {
|
||||
"message": "Заметность"
|
||||
},
|
||||
"landing.creator.feature.diverse-ecosystem.description": {
|
||||
"message": "Подключайте инструменты сборки через Minotaur и публикуйте новые версии автоматически"
|
||||
"message": "Подключайте инструменты сборки через Minotaur и публикуйте новые версии автоматически"
|
||||
},
|
||||
"landing.creator.feature.diverse-ecosystem.title": {
|
||||
"message": "Разнообразная экосистема"
|
||||
"message": "Развитая экосистема"
|
||||
},
|
||||
"landing.creator.feature.monetization.description": {
|
||||
"message": "Получайте доход от рекламы на страницах проектов и выводите средства в любое время"
|
||||
"message": "Получайте доход от рекламы на страницах проектов и выводите средства в любое время"
|
||||
},
|
||||
"landing.creator.feature.monetization.title": {
|
||||
"message": "Монетизация"
|
||||
},
|
||||
"landing.creator.feature.team-management.description": {
|
||||
"message": "Приглашайте участников и управляйте ролями и разрешениями с лёгкостью"
|
||||
"message": "Приглашайте участников и управляйте ролями и разрешениями без хлопот"
|
||||
},
|
||||
"landing.creator.feature.team-management.title": {
|
||||
"message": "Управление командой"
|
||||
@@ -1353,19 +1362,19 @@
|
||||
"message": "Не удалось загрузить случайные проекты :("
|
||||
},
|
||||
"landing.feature.follow.description": {
|
||||
"message": "Узнавайте о каждом обновлении и оставайтесь на связи с любимыми проектами."
|
||||
"message": "Узнавайте о каждом обновлении и оставайтесь на связи с любимыми проектами."
|
||||
},
|
||||
"landing.feature.follow.heading": {
|
||||
"message": "Следите за тем, что нравится"
|
||||
},
|
||||
"landing.feature.launcher.description": {
|
||||
"message": "Наш открытый API позволяет глубже интегрироваться с Modrinth — используйте <link>наше приложение</link> или сторонние лаунчеры наподобие ATLauncher, MultiMC и Prism Launcher."
|
||||
"message": "Наш открытый API позволяет глубже интегрироваться с Modrinth — используйте <link>наше приложение</link> или сторонние лаунчеры наподобие ATLauncher, MultiMC и Prism Launcher."
|
||||
},
|
||||
"landing.feature.launcher.heading": {
|
||||
"message": "Играйте через свой лаунчер"
|
||||
},
|
||||
"landing.feature.search.description": {
|
||||
"message": "Мгновенный поиск и мощные фильтры Modrinth позволяют находить контент уже во время ввода."
|
||||
"message": "Мгновенный поиск и мощные фильтры Modrinth позволяют находить контент уже во время ввода."
|
||||
},
|
||||
"landing.feature.search.heading": {
|
||||
"message": "Ищите нужное быстро и легко"
|
||||
@@ -1419,16 +1428,16 @@
|
||||
"message": "Выпущена версия {version} для {gameVersion}"
|
||||
},
|
||||
"landing.section.for-creators.description": {
|
||||
"message": "Разместите свои творения и найдите новых поклонников среди огромного сообщества игроков."
|
||||
"message": "Разместите свои проекты и найдите новых поклонников среди огромного сообщества игроков."
|
||||
},
|
||||
"landing.section.for-creators.label": {
|
||||
"message": "Для авторов"
|
||||
},
|
||||
"landing.section.for-creators.tagline": {
|
||||
"message": "Делитесь творениями с миром"
|
||||
"message": "Делитесь творениями с миром"
|
||||
},
|
||||
"landing.section.for-players.description": {
|
||||
"message": "От магических биомов до проклятых подземелий — вы точно найдёте контент, который выведет игру на новый уровень."
|
||||
"message": "От магических биомов до проклятых подземелий — вы точно найдёте контент, который выведет игру на новый уровень."
|
||||
},
|
||||
"landing.section.for-players.label": {
|
||||
"message": "Для игроков"
|
||||
@@ -1473,13 +1482,13 @@
|
||||
"message": "Опубликовать"
|
||||
},
|
||||
"layout.action.reports": {
|
||||
"message": "Рассмотреть жалобы"
|
||||
"message": "Рассмотрение жалоб"
|
||||
},
|
||||
"layout.action.review-projects": {
|
||||
"message": "Проверка проектов"
|
||||
},
|
||||
"layout.action.tech-review": {
|
||||
"message": "Техническая проверка"
|
||||
"message": "Техпроверка"
|
||||
},
|
||||
"layout.avatar.alt": {
|
||||
"message": "Ваш аватар"
|
||||
@@ -1494,10 +1503,10 @@
|
||||
"message": "В целях безопасности Modrinth просит привязать почту к аккаунту."
|
||||
},
|
||||
"layout.banner.build-fail.description": {
|
||||
"message": "При развёртывании Modrinth не удалось сгенерировать состояние от API. Причиной может быть временный сбой или неверная настройка. Повторите сборку, когда API будет доступен. Коды ошибок: {errors}; Текущий URL-адрес API: {url}"
|
||||
"message": "При развёртывании фронтенда Modrinth не удалось сгенерировать состояние от API. Причиной может быть временный сбой или неверная настройка. Пересоберите проект, когда API станет доступен. Коды ошибок: {errors}. Текущий адрес API: {url}"
|
||||
},
|
||||
"layout.banner.build-fail.title": {
|
||||
"message": "Ошибка генерации состояния от API во время сборки."
|
||||
"message": "Ошибка генерации состояния от API во время сборки."
|
||||
},
|
||||
"layout.banner.preview.description": {
|
||||
"message": "Официальный сайт Modrinth доступен по адресу <link>https://modrinth.com</link>. Текущая предварительная версия предназначена для тестирования командой Modrinth и собрана из <branch-link>{owner}/{branch}</branch-link> @ {commit}."
|
||||
@@ -1506,25 +1515,25 @@
|
||||
"message": "Это предварительная версия сайта Modrinth."
|
||||
},
|
||||
"layout.banner.staging.description": {
|
||||
"message": "Тестовая среда полностью отделена от основной базы данных Modrinth и нужна для тестирования и отладки. Протеид и бекхенд здесь могут быть новее, чем на основном сайте."
|
||||
"message": "Тестовая среда полностью отделена от основной базы данных Modrinth и нужна для тестирования и отладки. Фронтенд и бэкенд здесь могут содержать изменения, которые ещё не вышли на основном сайте."
|
||||
},
|
||||
"layout.banner.staging.title": {
|
||||
"message": "Вы находитесь в тестовой среде Modrinth"
|
||||
"message": "Вы находитесь в тестовой среде Modrinth"
|
||||
},
|
||||
"layout.banner.subscription-payment-failed.button": {
|
||||
"message": "Обновить платёжные данные"
|
||||
},
|
||||
"layout.banner.subscription-payment-failed.description": {
|
||||
"message": "Не удалось продлить одну или несколько подписок. Обновите способ оплаты, чтобы не потерять доступ!"
|
||||
"message": "Не удалось продлить одну или несколько подписок. Обновите способ оплаты, чтобы сохранить доступ."
|
||||
},
|
||||
"layout.banner.subscription-payment-failed.title": {
|
||||
"message": "Требуются действия с платёжными данными."
|
||||
"message": "Проблема с оплатой."
|
||||
},
|
||||
"layout.banner.tax.action": {
|
||||
"message": "Заполнить налоговую форму"
|
||||
},
|
||||
"layout.banner.tax.description": {
|
||||
"message": "В этом году вы вывели уже более {threshold} из Modrinth. В соответствии с налоговым законодательством необходимо заполнить налоговую форму. Вывод средств приостановлен, пока форма не будет заполнена."
|
||||
"message": "В этом году вы вывели уже более {threshold} из Modrinth. Для соблюдения налогового законодательства нужно заполнить налоговую форму. Вывод средств приостановлен до подачи этой формы."
|
||||
},
|
||||
"layout.banner.tax.title": {
|
||||
"message": "Требуется налоговая форма"
|
||||
@@ -1533,7 +1542,7 @@
|
||||
"message": "Связаться с поддержкой"
|
||||
},
|
||||
"layout.banner.tin-mismatch.description": {
|
||||
"message": "Вывод средств временно заблокирован — указанный TIN или SSN не совпал с данными IRS. Свяжитесь с поддержкой для повторной отправки формы."
|
||||
"message": "Вывод средств временно заблокирован: указанный TIN или SSN не совпал с данными IRS. Свяжитесь с поддержкой для повторной отправки формы."
|
||||
},
|
||||
"layout.banner.tin-mismatch.title": {
|
||||
"message": "Налоговая форма отклонена"
|
||||
@@ -1857,10 +1866,10 @@
|
||||
"message": "Аргентинский идентификационный номер налогоплательщика"
|
||||
},
|
||||
"muralpay.help.iban": {
|
||||
"message": "Международный номер банковского счёта"
|
||||
"message": "International Bank Account Number"
|
||||
},
|
||||
"muralpay.help.swift-bic": {
|
||||
"message": "Банковский идентификационный код"
|
||||
"message": "Bank Identifier Code"
|
||||
},
|
||||
"muralpay.pix-type.bank-account": {
|
||||
"message": "Банковский счёт"
|
||||
@@ -2094,7 +2103,7 @@
|
||||
"message": "Вы присоединились к команде проекта"
|
||||
},
|
||||
"project-moderation-nags.publishing-checklist": {
|
||||
"message": "Контрольный список для публикации"
|
||||
"message": "Контрольный список для публикации"
|
||||
},
|
||||
"project-moderation-nags.required": {
|
||||
"message": "Обязательно"
|
||||
@@ -2106,7 +2115,7 @@
|
||||
"message": "Проект {status, select, rejected {отклонён} withheld {на удержании} other {{status}}} командой Modrinth. Обычно после решения указанных модератором проблем можно снова запросить проверку."
|
||||
},
|
||||
"project-moderation-nags.submit-checklist-tooltip": {
|
||||
"message": "Необходимо выполнить обязательные действия в контрольном списке!"
|
||||
"message": "Необходимо выполнить обязательные действия в контрольном списке!"
|
||||
},
|
||||
"project-moderation-nags.submit-for-review": {
|
||||
"message": "Запросите проверку"
|
||||
@@ -2115,7 +2124,7 @@
|
||||
"message": "Отправить на проверку"
|
||||
},
|
||||
"project-moderation-nags.submit-for-review-desc": {
|
||||
"message": "Проект сейчас виден только участникам. Для публикации он должен пройти проверку модераторами."
|
||||
"message": "Проект сейчас виден только участникам. Для публикации он должен пройти проверку модераторами."
|
||||
},
|
||||
"project-moderation-nags.suggestion": {
|
||||
"message": "Предложение"
|
||||
@@ -3510,10 +3519,10 @@
|
||||
"message": "Уникальное имя для идентификации профиля. Регистр не имеет значения."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Завершить сеанс"
|
||||
"message": "Завершить сеанс"
|
||||
},
|
||||
"settings.sessions.created-ago": {
|
||||
"message": "Создано {ago}"
|
||||
"message": "Начат {ago}"
|
||||
},
|
||||
"settings.sessions.current-session": {
|
||||
"message": "Текущий сеанс"
|
||||
@@ -3522,7 +3531,7 @@
|
||||
"message": "Ниже представлены все устройства, с которых осуществлён вход в вашу учётную запись Modrinth. Вы можете управлять сеансом каждого из них по отдельности.\n\nЕсли среди них присутствует незнакомое устройство, незамедлительно завершите его сеанс и смените пароль своей учётной записи."
|
||||
},
|
||||
"settings.sessions.last-accessed-ago": {
|
||||
"message": "Последняя активность {ago}"
|
||||
"message": "Активность {ago}"
|
||||
},
|
||||
"settings.sessions.unknown-os": {
|
||||
"message": "Неизвестная ОС"
|
||||
@@ -3540,7 +3549,7 @@
|
||||
"message": "Отображение"
|
||||
},
|
||||
"ui.latest-news-row.latest-news": {
|
||||
"message": "Последние новости от Modrinth"
|
||||
"message": "Последние новости от Modrinth"
|
||||
},
|
||||
"ui.latest-news-row.view-all": {
|
||||
"message": "Посмотреть все новости"
|
||||
|
||||
@@ -572,6 +572,12 @@
|
||||
"dashboard.affiliate-links.search": {
|
||||
"message": "Sök affiliatelänkar..."
|
||||
},
|
||||
"dashboard.analytics.total-downloads": {
|
||||
"message": "Antal nedladdningar"
|
||||
},
|
||||
"dashboard.analytics.total-followers": {
|
||||
"message": "Antal följare"
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "Skapa ny"
|
||||
},
|
||||
@@ -596,6 +602,18 @@
|
||||
"dashboard.collections.long-title": {
|
||||
"message": "Dina samlingar"
|
||||
},
|
||||
"dashboard.collections.placeholder.search": {
|
||||
"message": "Sök samlingar..."
|
||||
},
|
||||
"dashboard.collections.sort.name-ascending": {
|
||||
"message": "Namn (A-Z)"
|
||||
},
|
||||
"dashboard.collections.sort.recently-created": {
|
||||
"message": "Nyligen skapat"
|
||||
},
|
||||
"dashboard.collections.sort.recently-updated": {
|
||||
"message": "Nyligen uppdaterade"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.download-button": {
|
||||
"message": "Ladda ner {formType}"
|
||||
},
|
||||
@@ -794,6 +812,12 @@
|
||||
"dashboard.creator-withdraw-modal.tax-form-required.header": {
|
||||
"message": "Skatteformulär krävs"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.tremendous-details.available-denominations-label": {
|
||||
"message": "Tillgängliga valutor"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.tremendous-details.balance-worth-hint": {
|
||||
"message": "Ditt saldo av {usdBalance} är för tillfället värt {localBalance}."
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.tremendous-details.enter-amount-hint": {
|
||||
"message": "Hitta presentkort nära detta värde."
|
||||
},
|
||||
@@ -815,6 +839,12 @@
|
||||
"dashboard.creator-withdraw-modal.tremendous-details.search-amount-label": {
|
||||
"message": "Sök antal"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.tremendous-details.select-denomination-hint": {
|
||||
"message": "Välj en valuta:"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.tremendous-details.select-denomination-required": {
|
||||
"message": "Vänligen välj en valuta för att fortsätta"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.tremendous-details.unverified-email-header": {
|
||||
"message": "Overifierad e-postadress"
|
||||
},
|
||||
@@ -920,6 +950,9 @@
|
||||
"dashboard.withdraw.error.email-verification.title": {
|
||||
"message": "E-post verifiering krävs"
|
||||
},
|
||||
"dashboard.withdraw.error.generic.title": {
|
||||
"message": "Kan inte ta ut pengar"
|
||||
},
|
||||
"dashboard.withdraw.error.insufficient-balance.title": {
|
||||
"message": "Otillräcklig saldo"
|
||||
},
|
||||
@@ -947,6 +980,15 @@
|
||||
"dashboard.withdraw.error.minimum-not-met.title": {
|
||||
"message": "Belopp för lågt"
|
||||
},
|
||||
"dashboard.withdraw.error.paypal-country-mismatch.title": {
|
||||
"message": "PayPal-regionen matchar inte"
|
||||
},
|
||||
"dashboard.withdraw.error.tax-form.text": {
|
||||
"message": "Du måste fylla i en skatteblankett för att fullfölja din uttagsbeställning."
|
||||
},
|
||||
"dashboard.withdraw.error.tax-form.title": {
|
||||
"message": "Vänligen fyll i skatteblanketten"
|
||||
},
|
||||
"error.collection.404.list_item.1": {
|
||||
"message": "Du kan ha skrivit fel på samlingens URL."
|
||||
},
|
||||
@@ -1463,6 +1505,9 @@
|
||||
"layout.banner.tin-mismatch.action": {
|
||||
"message": "Kontakta support"
|
||||
},
|
||||
"layout.banner.tin-mismatch.title": {
|
||||
"message": "Skatteblanketten avvisades"
|
||||
},
|
||||
"layout.banner.verify-email.action": {
|
||||
"message": "Skicka nytt bekräftelsemejl"
|
||||
},
|
||||
@@ -1631,6 +1676,12 @@
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "Teknisk recension"
|
||||
},
|
||||
"muralpay.account-type.checking": {
|
||||
"message": "Checkkonto"
|
||||
},
|
||||
"muralpay.account-type.savings": {
|
||||
"message": "Sparkonto"
|
||||
},
|
||||
"muralpay.country.at": {
|
||||
"message": "Österrike"
|
||||
},
|
||||
@@ -1694,6 +1745,9 @@
|
||||
"muralpay.document-type.resident-id": {
|
||||
"message": "Medborgar ID"
|
||||
},
|
||||
"muralpay.document-type.tax-id": {
|
||||
"message": "Skatte-ID"
|
||||
},
|
||||
"muralpay.field.account-number": {
|
||||
"message": "Kontonummer"
|
||||
},
|
||||
@@ -1709,9 +1763,21 @@
|
||||
"muralpay.field.country": {
|
||||
"message": "Land"
|
||||
},
|
||||
"muralpay.field.document-type": {
|
||||
"message": "Dokumenttyp"
|
||||
},
|
||||
"muralpay.field.phone-number": {
|
||||
"message": "Telefonnummer"
|
||||
},
|
||||
"muralpay.field.random-key": {
|
||||
"message": "Slumpmässigt genererad nyckel"
|
||||
},
|
||||
"muralpay.field.wallet-address": {
|
||||
"message": "Plånboksadress"
|
||||
},
|
||||
"muralpay.help.cbu-cvu": {
|
||||
"message": "Clave Bancaria Uniforme eller Clave Virtual Uniforme"
|
||||
},
|
||||
"muralpay.pix-type.bank-account": {
|
||||
"message": "Bankkonto"
|
||||
},
|
||||
@@ -1721,6 +1787,12 @@
|
||||
"muralpay.pix-type.phone": {
|
||||
"message": "Telefonnummer"
|
||||
},
|
||||
"muralpay.placeholder.pix-phone": {
|
||||
"message": "+55..."
|
||||
},
|
||||
"muralpay.placeholder.wallet-address-eth": {
|
||||
"message": "0x..."
|
||||
},
|
||||
"muralpay.rail.fiat-ars.name": {
|
||||
"message": "Banköverföring (ARS)"
|
||||
},
|
||||
|
||||
@@ -2930,6 +2930,9 @@
|
||||
"settings.account.security.email.action.change": {
|
||||
"message": "Eposta değiştir"
|
||||
},
|
||||
"settings.account.security.email.description": {
|
||||
"message": "Hesabınıza bağlı e-posta adresini değiştirir."
|
||||
},
|
||||
"settings.account.security.email.title": {
|
||||
"message": "Eposta"
|
||||
},
|
||||
@@ -2939,9 +2942,21 @@
|
||||
"settings.account.security.password.action.change": {
|
||||
"message": "Parolayı değiştir"
|
||||
},
|
||||
"settings.account.security.password.description.change": {
|
||||
"message": "Hesabınıza giriş yapmak için kullanılan şifreyi değiştirin."
|
||||
},
|
||||
"settings.account.security.password.description.change-or-remove": {
|
||||
"message": "Hesabınızla ilişkili e-posta adresini değiştirir."
|
||||
},
|
||||
"settings.account.security.password.description.set": {
|
||||
"message": "Hesabınıza giriş yapmak için kalıcı bir şifre belirleyin."
|
||||
},
|
||||
"settings.account.security.password.title": {
|
||||
"message": "Parola"
|
||||
},
|
||||
"settings.account.security.providers.description": {
|
||||
"message": "Hesabınıza giriş yöntemleri ekleyin veya kaldırın; GitHub, GitLab, Microsoft, Discord, Steam ve Google dahil."
|
||||
},
|
||||
"settings.account.security.title": {
|
||||
"message": "Hesap güvenliği"
|
||||
},
|
||||
@@ -2951,6 +2966,9 @@
|
||||
"settings.account.security.two-factor.action.setup": {
|
||||
"message": "İki aşamalı doğrulama kur"
|
||||
},
|
||||
"settings.account.security.two-factor.description": {
|
||||
"message": "Hesabınıza giriş yaparken ek bir güvenlik katmanı ekleyin."
|
||||
},
|
||||
"settings.account.security.two-factor.title": {
|
||||
"message": "İki aşamalı doğrulama"
|
||||
},
|
||||
@@ -3065,6 +3083,15 @@
|
||||
"settings.authorizations.by": {
|
||||
"message": "tarafından"
|
||||
},
|
||||
"settings.authorizations.description": {
|
||||
"message": "Modrinth hesabınızla bir uygulamayı yetkilendirdiğinizde, ona hesabınıza erişim izni vermiş olursunuz. Hesabınıza verilen erişimi dilediğiniz zaman burada yönetebilir ve gözden geçirebilirsiniz."
|
||||
},
|
||||
"settings.authorizations.empty-state": {
|
||||
"message": "Yetkilendirdiğiniz uygulamalar şu anda görüntülenemiyor, bunu düzeltmek için çalışıyoruz. Lütfen bu sayfayı daha sonra tekrar ziyaret edin."
|
||||
},
|
||||
"settings.authorizations.head-title": {
|
||||
"message": "Yetkiler"
|
||||
},
|
||||
"settings.authorizations.revoke.action": {
|
||||
"message": "Kaldır"
|
||||
},
|
||||
@@ -3092,6 +3119,9 @@
|
||||
"settings.billing.midas.benefits.title": {
|
||||
"message": "Yararlar"
|
||||
},
|
||||
"settings.billing.midas.upsell": {
|
||||
"message": "Modrinth Plus’a abone olun!"
|
||||
},
|
||||
"settings.billing.modal.cancel.action": {
|
||||
"message": "Aboneliği iptal et"
|
||||
},
|
||||
@@ -3113,6 +3143,9 @@
|
||||
"settings.billing.next": {
|
||||
"message": "Sonraki:"
|
||||
},
|
||||
"settings.billing.or-yearly-save": {
|
||||
"message": "Veya {price} / yıl %({percent} tasarruf edin)!"
|
||||
},
|
||||
"settings.billing.payment_method.action.add": {
|
||||
"message": "Ödeme yöntemi ekle"
|
||||
},
|
||||
@@ -3287,6 +3320,9 @@
|
||||
"settings.pats.action.create": {
|
||||
"message": "Bir PAT yarat"
|
||||
},
|
||||
"settings.pats.description": {
|
||||
"message": "PAT'ler Modrinth API’sine erişmek için kullanılabilir. Bunlar istendiği zaman oluşturulabilir ve iptal edilebilir. Daha fazla bilgi için <doc-link>Modrinth API dokümantasyonuna</doc-link> bakın."
|
||||
},
|
||||
"settings.pats.modal.create.action": {
|
||||
"message": "PAT oluştur"
|
||||
},
|
||||
|
||||
@@ -423,7 +423,7 @@
|
||||
"message": "输入收藏夹名称………"
|
||||
},
|
||||
"create.collection.summary-description": {
|
||||
"message": "一两句话描述你的收藏夹。"
|
||||
"message": "用一两句话描述你的收藏夹。"
|
||||
},
|
||||
"create.collection.summary-label": {
|
||||
"message": "简介"
|
||||
@@ -554,6 +554,9 @@
|
||||
"dashboard.affiliate-links.create.button": {
|
||||
"message": "创建推广链接"
|
||||
},
|
||||
"dashboard.affiliate-links.empty.no-codes": {
|
||||
"message": "没有找到推广代码。"
|
||||
},
|
||||
"dashboard.affiliate-links.error.title": {
|
||||
"message": "加载推广链接时出错"
|
||||
},
|
||||
@@ -572,11 +575,20 @@
|
||||
"dashboard.affiliate-links.search": {
|
||||
"message": "搜索推广链接…"
|
||||
},
|
||||
"dashboard.analytics.from-projects": {
|
||||
"message": "来自{count}个项目"
|
||||
},
|
||||
"dashboard.analytics.total-downloads": {
|
||||
"message": "总下载量"
|
||||
},
|
||||
"dashboard.analytics.total-followers": {
|
||||
"message": "总关注者"
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "新建"
|
||||
},
|
||||
"dashboard.collections.empty.get-started-hint": {
|
||||
"message": "创建你的首个收藏夹,开始吧!"
|
||||
"message": "创建您的第一个收藏夹,开始新的旅程!"
|
||||
},
|
||||
"dashboard.collections.empty.no-collections": {
|
||||
"message": "你还没有任何收藏夹"
|
||||
@@ -596,6 +608,18 @@
|
||||
"dashboard.collections.long-title": {
|
||||
"message": "你的收藏夹"
|
||||
},
|
||||
"dashboard.collections.placeholder.search": {
|
||||
"message": "搜索收藏夹……"
|
||||
},
|
||||
"dashboard.collections.sort.name-ascending": {
|
||||
"message": "名称(A-Z)"
|
||||
},
|
||||
"dashboard.collections.sort.recently-created": {
|
||||
"message": "最近发布"
|
||||
},
|
||||
"dashboard.collections.sort.recently-updated": {
|
||||
"message": "最近更新"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.download-button": {
|
||||
"message": "下载{formType}"
|
||||
},
|
||||
@@ -714,7 +738,7 @@
|
||||
"message": "国民身份证号码"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.muralpay-details.document-number-national-id-placeholder": {
|
||||
"message": "输入国民身份证号码"
|
||||
"message": "输入居民身份证号码"
|
||||
},
|
||||
"dashboard.creator-withdraw-modal.muralpay-details.document-number-passport": {
|
||||
"message": "护照号码"
|
||||
@@ -848,6 +872,168 @@
|
||||
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
|
||||
"message": "你已用尽<b>{withdrawLimit}</b>的提现额度。若需继续提现,请先完成税务表格填写。"
|
||||
},
|
||||
"dashboard.head-title": {
|
||||
"message": "数据面板"
|
||||
},
|
||||
"dashboard.notifications.button.mark-all-as-read": {
|
||||
"message": "标记全部已读"
|
||||
},
|
||||
"dashboard.notifications.button.view-history": {
|
||||
"message": "查看历史"
|
||||
},
|
||||
"dashboard.notifications.empty.no-unread": {
|
||||
"message": "你没有未读通知。"
|
||||
},
|
||||
"dashboard.notifications.error.loading": {
|
||||
"message": "加载通知时出错:"
|
||||
},
|
||||
"dashboard.notifications.history.label": {
|
||||
"message": "历史"
|
||||
},
|
||||
"dashboard.notifications.history.title": {
|
||||
"message": "历史通知"
|
||||
},
|
||||
"dashboard.notifications.link.see-all": {
|
||||
"message": "查看所有"
|
||||
},
|
||||
"dashboard.notifications.link.view-history": {
|
||||
"message": "查看历史通知"
|
||||
},
|
||||
"dashboard.notifications.link.view-more": {
|
||||
"message": "查看更多 {extraNotifs} 个通知"
|
||||
},
|
||||
"dashboard.notifications.loading": {
|
||||
"message": "加载通知……"
|
||||
},
|
||||
"dashboard.organizations.button.create": {
|
||||
"message": "创建组织"
|
||||
},
|
||||
"dashboard.organizations.empty.cta": {
|
||||
"message": "建立一个组织!"
|
||||
},
|
||||
"dashboard.organizations.error.fetch": {
|
||||
"message": "获取组织失败"
|
||||
},
|
||||
"dashboard.organizations.member-count": {
|
||||
"message": "{count} 个成员"
|
||||
},
|
||||
"dashboard.organizations.title": {
|
||||
"message": "组织"
|
||||
},
|
||||
"dashboard.projects.bulk-edit-hint": {
|
||||
"message": "你可以通过下方复选框选择多个项目来一次性编辑。"
|
||||
},
|
||||
"dashboard.projects.bulk-edit.server-disabled": {
|
||||
"message": "服务器项目不支持批量编辑"
|
||||
},
|
||||
"dashboard.projects.empty": {
|
||||
"message": "你还没有任何项目。点击上面的绿色按钮开始。"
|
||||
},
|
||||
"dashboard.projects.head-title": {
|
||||
"message": "项目"
|
||||
},
|
||||
"dashboard.projects.links.and-more": {
|
||||
"message": "还有 {count} 个……"
|
||||
},
|
||||
"dashboard.projects.links.button.clear-link": {
|
||||
"message": "清除链接"
|
||||
},
|
||||
"dashboard.projects.links.button.edit": {
|
||||
"message": "编辑链接"
|
||||
},
|
||||
"dashboard.projects.links.changes-applied": {
|
||||
"message": "将应用更改到 <strong>{count}</strong> 个项目"
|
||||
},
|
||||
"dashboard.projects.links.description": {
|
||||
"message": "以下你指定的任何链接,都会覆盖所选项目上的现有链接。留空的内容将被忽略。你可以使用垃圾桶按钮从所有选定的项目中清除某个链接。"
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.description": {
|
||||
"message": "你 Discord 服务器的邀请链接。"
|
||||
},
|
||||
"dashboard.projects.links.discord-invite.label": {
|
||||
"message": "Discord 邀请"
|
||||
},
|
||||
"dashboard.projects.links.issue-tracker.description": {
|
||||
"message": "一个供用户报告你项目中的错误、问题及疑虑的地方。"
|
||||
},
|
||||
"dashboard.projects.links.issue-tracker.label": {
|
||||
"message": "问题追踪器"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.cleared": {
|
||||
"message": "现有链接将被清除"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-discord-url": {
|
||||
"message": "请输入有效的 Discord 邀请链接"
|
||||
},
|
||||
"dashboard.projects.links.placeholder.valid-url": {
|
||||
"message": "输入一个有效的网址"
|
||||
},
|
||||
"dashboard.projects.links.show-all-projects": {
|
||||
"message": "显示所有项目"
|
||||
},
|
||||
"dashboard.projects.links.source-code.description": {
|
||||
"message": "一个包含你项目源代码的页面/仓库"
|
||||
},
|
||||
"dashboard.projects.links.source-code.label": {
|
||||
"message": "源代码"
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.description": {
|
||||
"message": "一个包含项目信息、文档和帮助的页面。"
|
||||
},
|
||||
"dashboard.projects.links.wiki-page.label": {
|
||||
"message": "维基页面"
|
||||
},
|
||||
"dashboard.projects.notification.bulk-edit-success": {
|
||||
"message": "已批量编辑所选项目的链接。"
|
||||
},
|
||||
"dashboard.projects.project.icon-alt": {
|
||||
"message": "{title} 的图标"
|
||||
},
|
||||
"dashboard.projects.project.moderator-message-aria": {
|
||||
"message": "项目有一则来自版主的消息。查看项目以了解更多。"
|
||||
},
|
||||
"dashboard.projects.project.review-environment-metadata": {
|
||||
"message": "请复核运行环境元数据"
|
||||
},
|
||||
"dashboard.projects.sort.ascending": {
|
||||
"message": "正序"
|
||||
},
|
||||
"dashboard.projects.sort.descending": {
|
||||
"message": "降序"
|
||||
},
|
||||
"dashboard.projects.sort.option.name": {
|
||||
"message": "名称"
|
||||
},
|
||||
"dashboard.projects.sort.option.status": {
|
||||
"message": "状态"
|
||||
},
|
||||
"dashboard.projects.sort.option.type": {
|
||||
"message": "类型"
|
||||
},
|
||||
"dashboard.projects.table.icon": {
|
||||
"message": "图标"
|
||||
},
|
||||
"dashboard.projects.table.id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"dashboard.projects.table.name": {
|
||||
"message": "名称"
|
||||
},
|
||||
"dashboard.projects.table.status": {
|
||||
"message": "状态"
|
||||
},
|
||||
"dashboard.projects.table.type": {
|
||||
"message": "类型"
|
||||
},
|
||||
"dashboard.report.title": {
|
||||
"message": "举报 {id}"
|
||||
},
|
||||
"dashboard.reports.active-title": {
|
||||
"message": "处理中的举报"
|
||||
},
|
||||
"dashboard.reports.title": {
|
||||
"message": "举报"
|
||||
},
|
||||
"dashboard.revenue.available-now": {
|
||||
"message": "可用"
|
||||
},
|
||||
@@ -855,7 +1041,7 @@
|
||||
"message": "余额"
|
||||
},
|
||||
"dashboard.revenue.estimated-tooltip.msg1": {
|
||||
"message": "预估收入可能随时变更,直至正式公布为止。"
|
||||
"message": "预计收入在公布前可能会有所调整。"
|
||||
},
|
||||
"dashboard.revenue.estimated-tooltip.msg2": {
|
||||
"message": "点击了解 Modrinth 如何处理你的收入。"
|
||||
@@ -884,6 +1070,9 @@
|
||||
"dashboard.revenue.transactions.btn.download-csv": {
|
||||
"message": "以 CSV 格式下载"
|
||||
},
|
||||
"dashboard.revenue.transactions.head-title": {
|
||||
"message": "交易记录"
|
||||
},
|
||||
"dashboard.revenue.transactions.header": {
|
||||
"message": "账单"
|
||||
},
|
||||
@@ -893,9 +1082,18 @@
|
||||
"dashboard.revenue.transactions.none.desc": {
|
||||
"message": "你的支付和提现记录将在此区域显示。"
|
||||
},
|
||||
"dashboard.revenue.transactions.period.last-month": {
|
||||
"message": "上个月"
|
||||
},
|
||||
"dashboard.revenue.transactions.period.this-month": {
|
||||
"message": "这个月"
|
||||
},
|
||||
"dashboard.revenue.transactions.see-all": {
|
||||
"message": "查看所有"
|
||||
},
|
||||
"dashboard.revenue.transactions.year.all": {
|
||||
"message": "所有年份"
|
||||
},
|
||||
"dashboard.revenue.withdraw.blocked-tin-mismatch": {
|
||||
"message": "由于你的纳税人识别号或社会安全号码与美国国税局记录不符,你的提现被暂时锁定。请联系客服重置并重新提交你的税表。"
|
||||
},
|
||||
@@ -908,6 +1106,33 @@
|
||||
"dashboard.revenue.withdraw.header": {
|
||||
"message": "提现"
|
||||
},
|
||||
"dashboard.sidebar.label.activeReports": {
|
||||
"message": "处理中的举报"
|
||||
},
|
||||
"dashboard.sidebar.label.analytics": {
|
||||
"message": "分析"
|
||||
},
|
||||
"dashboard.sidebar.label.creators": {
|
||||
"message": "作者"
|
||||
},
|
||||
"dashboard.sidebar.label.dashboard": {
|
||||
"message": "数据面板"
|
||||
},
|
||||
"dashboard.sidebar.label.notifications": {
|
||||
"message": "通知"
|
||||
},
|
||||
"dashboard.sidebar.label.organizations": {
|
||||
"message": "组织"
|
||||
},
|
||||
"dashboard.sidebar.label.overview": {
|
||||
"message": "概述"
|
||||
},
|
||||
"dashboard.sidebar.label.projects": {
|
||||
"message": "项目"
|
||||
},
|
||||
"dashboard.sidebar.label.revenue": {
|
||||
"message": "收益"
|
||||
},
|
||||
"dashboard.withdraw.completion.account": {
|
||||
"message": "账户"
|
||||
},
|
||||
@@ -2115,7 +2340,7 @@
|
||||
"message": "提交审核"
|
||||
},
|
||||
"project-moderation-nags.submit-for-review-desc": {
|
||||
"message": "你的项目现在只能被项目成员查看。必须经过管理员审核才能公开发布项目。"
|
||||
"message": "您的项目仅对项目成员可见。必须经过管理员审核后才能发布。"
|
||||
},
|
||||
"project-moderation-nags.suggestion": {
|
||||
"message": "建议"
|
||||
@@ -2844,7 +3069,7 @@
|
||||
"message": "注销账户"
|
||||
},
|
||||
"settings.account.delete.section.description": {
|
||||
"message": "一旦删除账户,将无法复原,请谨慎度量。删除你的账户将从我们的服务器上删除所有附加的数据,但项目除外。"
|
||||
"message": "删除账户后将无法恢复,请慎重。账户删除后,除项目外,所有关联数据都将从服务器中移除。"
|
||||
},
|
||||
"settings.account.delete.section.title": {
|
||||
"message": "删除账户"
|
||||
@@ -2943,13 +3168,13 @@
|
||||
"message": "更改密码"
|
||||
},
|
||||
"settings.account.security.password.description.change": {
|
||||
"message": "更改用于登录你账户的密码。"
|
||||
"message": "更改登录账户时使用的密码。"
|
||||
},
|
||||
"settings.account.security.password.description.change-or-remove": {
|
||||
"message": "更改或删除用于登录你账户的密码。"
|
||||
"message": "更改或删除用于登录账户的密码。"
|
||||
},
|
||||
"settings.account.security.password.description.set": {
|
||||
"message": "设置一个永久密码以登录你的账户。"
|
||||
"message": "设置一个永久密码来登录您的账户。"
|
||||
},
|
||||
"settings.account.security.password.title": {
|
||||
"message": "密码"
|
||||
@@ -2958,7 +3183,7 @@
|
||||
"message": "管理供应商"
|
||||
},
|
||||
"settings.account.security.providers.description": {
|
||||
"message": "向你的账户添加或移除登录方式,包括 GitHub、GitLab、Microsoft、Discord、Steam 和 Google。"
|
||||
"message": "向您的账户添加或移除登录方式,包括 GitHub、GitLab、Microsoft、Discord、Steam 和 Google。"
|
||||
},
|
||||
"settings.account.security.providers.title": {
|
||||
"message": "管理身份验证提供程序"
|
||||
@@ -2973,7 +3198,7 @@
|
||||
"message": "设置 2FA"
|
||||
},
|
||||
"settings.account.security.two-factor.description": {
|
||||
"message": "在登录时为你的账户增加一层额外的安全保障。"
|
||||
"message": "在登录时为您的账户增加一层额外的安全保障。"
|
||||
},
|
||||
"settings.account.security.two-factor.title": {
|
||||
"message": "双因素认证"
|
||||
@@ -3105,7 +3330,7 @@
|
||||
"message": "图标已更新"
|
||||
},
|
||||
"settings.applications.secret.disclaimer": {
|
||||
"message": "请立即保存你的密钥,离开当前页面后将不再显示!"
|
||||
"message": "请立即保存您的密钥,离开当前页面后将不再显示!"
|
||||
},
|
||||
"settings.authorizations.about-this-app": {
|
||||
"message": "关于此应用"
|
||||
@@ -3114,10 +3339,10 @@
|
||||
"message": "by"
|
||||
},
|
||||
"settings.authorizations.description": {
|
||||
"message": "当你使用 Modrinth 账户授权某个应用程序时,即表示你授予其访问你账户的权限。你可以随时在此处管理和审查对你账户的访问权限。"
|
||||
"message": "当您使用 Modrinth 账户授权某个应用程序时,即表示您授予其访问您账户的权限。您可以随时在此处管理和审查对您账户的访问权限。"
|
||||
},
|
||||
"settings.authorizations.empty-state": {
|
||||
"message": "我们目前无法显示你授权的应用程序,我们正在努力解决这个问题。请稍后再访问此页面!"
|
||||
"message": "我们目前无法显示您授权的应用程序,我们正在努力解决这个问题。请稍后再访问此页面!"
|
||||
},
|
||||
"settings.authorizations.head-title": {
|
||||
"message": "授权"
|
||||
@@ -3126,13 +3351,13 @@
|
||||
"message": "撤销"
|
||||
},
|
||||
"settings.authorizations.revoke.confirm.description": {
|
||||
"message": "这将撤销该应用程序对你账户的访问权限。你随时可以之后重新授权。"
|
||||
"message": "这将撤销该应用程序对您账户的访问权限。您随时可以之后重新授权。"
|
||||
},
|
||||
"settings.authorizations.revoke.confirm.title": {
|
||||
"message": "你确定要撤销此申请吗?"
|
||||
"message": "您确定要撤销此申请吗?"
|
||||
},
|
||||
"settings.billing.charges.description": {
|
||||
"message": "你过去所有支付给Modrinth账户的款项都将列在这里:"
|
||||
"message": "您过去所有支付给Modrinth账户的款项都将列在这里:"
|
||||
},
|
||||
"settings.billing.charges.product.medal-trial": {
|
||||
"message": "Medal 服务器试用"
|
||||
@@ -3372,7 +3597,7 @@
|
||||
"message": "网格"
|
||||
},
|
||||
"settings.display.project-list-layouts.mode.rows": {
|
||||
"message": "行"
|
||||
"message": "列表"
|
||||
},
|
||||
"settings.display.project-list-layouts.modpack": {
|
||||
"message": "整合包页面"
|
||||
|
||||
@@ -554,6 +554,9 @@
|
||||
"dashboard.affiliate-links.create.button": {
|
||||
"message": "建立聯盟連結"
|
||||
},
|
||||
"dashboard.affiliate-links.empty.no-codes": {
|
||||
"message": "未找到推廣代碼。"
|
||||
},
|
||||
"dashboard.affiliate-links.error.title": {
|
||||
"message": "載入聯盟連結時發生錯誤"
|
||||
},
|
||||
@@ -572,6 +575,15 @@
|
||||
"dashboard.affiliate-links.search": {
|
||||
"message": "搜尋聯盟連結..."
|
||||
},
|
||||
"dashboard.analytics.from-projects": {
|
||||
"message": "來自 {count} {count, plural, one {project} other {projects}}"
|
||||
},
|
||||
"dashboard.analytics.total-downloads": {
|
||||
"message": "總下載量"
|
||||
},
|
||||
"dashboard.analytics.total-followers": {
|
||||
"message": "總關注者"
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "新增"
|
||||
},
|
||||
@@ -596,6 +608,18 @@
|
||||
"dashboard.collections.long-title": {
|
||||
"message": "你的收藏"
|
||||
},
|
||||
"dashboard.collections.placeholder.search": {
|
||||
"message": "搜尋收藏集…"
|
||||
},
|
||||
"dashboard.collections.sort.name-ascending": {
|
||||
"message": "姓名(A-Z)"
|
||||
},
|
||||
"dashboard.collections.sort.recently-created": {
|
||||
"message": "最近創建"
|
||||
},
|
||||
"dashboard.collections.sort.recently-updated": {
|
||||
"message": "最近更新"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.download-button": {
|
||||
"message": "下載 {formType}"
|
||||
},
|
||||
@@ -848,6 +872,39 @@
|
||||
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
|
||||
"message": "你已用盡你的提領額度:<b>{withdrawLimit}</b>。你必須填寫一份稅務表單才能提領更多金額。"
|
||||
},
|
||||
"dashboard.head-title": {
|
||||
"message": "儀表板"
|
||||
},
|
||||
"dashboard.notifications.button.mark-all-as-read": {
|
||||
"message": "全部標記為已讀"
|
||||
},
|
||||
"dashboard.notifications.button.view-history": {
|
||||
"message": "查看歷史記錄"
|
||||
},
|
||||
"dashboard.notifications.empty.no-unread": {
|
||||
"message": "您沒有任何未讀通知。"
|
||||
},
|
||||
"dashboard.notifications.error.loading": {
|
||||
"message": "載入通知時發生錯誤:"
|
||||
},
|
||||
"dashboard.notifications.history.label": {
|
||||
"message": "歷史"
|
||||
},
|
||||
"dashboard.notifications.history.title": {
|
||||
"message": "通知歷史記錄"
|
||||
},
|
||||
"dashboard.notifications.link.see-all": {
|
||||
"message": "看全部"
|
||||
},
|
||||
"dashboard.notifications.link.view-history": {
|
||||
"message": "查看通知記錄"
|
||||
},
|
||||
"dashboard.notifications.link.view-more": {
|
||||
"message": "看更多 {extraNotifs} 條 {extraNotifs, plural, one {通知} other {通知}}"
|
||||
},
|
||||
"dashboard.notifications.loading": {
|
||||
"message": "正在載入通知…"
|
||||
},
|
||||
"dashboard.revenue.available-now": {
|
||||
"message": "可提領"
|
||||
},
|
||||
|
||||
@@ -754,10 +754,7 @@
|
||||
},
|
||||
{
|
||||
id: 'moderation-checklist',
|
||||
action: () => {
|
||||
moderationStore.setSingleProject(project.id)
|
||||
showModerationChecklist = true
|
||||
},
|
||||
action: openModerationChecklistFromMenu,
|
||||
color: 'orange',
|
||||
hoverOnly: true,
|
||||
shown:
|
||||
@@ -1046,7 +1043,7 @@
|
||||
>
|
||||
<ModerationChecklist
|
||||
:collapsed="collapsedModerationChecklist"
|
||||
@exit="showModerationChecklist = false"
|
||||
@exit="setModerationChecklistOpen(false)"
|
||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||
/>
|
||||
</div>
|
||||
@@ -1145,7 +1142,11 @@ import { saveFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
|
||||
import { versionQueryOptions } from '~/composables/queries/version'
|
||||
import { userCollectProject, userFollowProject } from '~/composables/user.js'
|
||||
import { useModerationStore } from '~/store/moderation.ts'
|
||||
import {
|
||||
loadChecklistOpenState,
|
||||
saveChecklistOpenState,
|
||||
} from '~/services/moderation-checklist-storage.ts'
|
||||
import { useModerationQueue } from '~/services/moderation-queue.ts'
|
||||
import { getReportPath, reportProject } from '~/utils/report-helpers.ts'
|
||||
|
||||
definePageMeta({
|
||||
@@ -1156,7 +1157,7 @@ const data = useNuxtApp()
|
||||
const route = useRoute()
|
||||
const signInRouteObj = computed(() => getSignInRouteObj(route))
|
||||
const config = useRuntimeConfig()
|
||||
const moderationStore = useModerationStore()
|
||||
const moderationQueue = useModerationQueue()
|
||||
const notifications = injectNotificationManager()
|
||||
const { addNotification } = notifications
|
||||
|
||||
@@ -2514,16 +2515,84 @@ async function copyPermalink() {
|
||||
|
||||
const collapsedChecklist = ref(false)
|
||||
|
||||
const showModerationChecklist = useLocalStorage(
|
||||
`show-moderation-checklist-${project.value?.id ?? 'unknown'}`,
|
||||
false,
|
||||
)
|
||||
const showModerationChecklist = ref(false)
|
||||
const collapsedModerationChecklist = useLocalStorage('collapsed-moderation-checklist', false)
|
||||
|
||||
if (import.meta.client && history && history.state && history.state.showChecklist) {
|
||||
showModerationChecklist.value = true
|
||||
function consumeShowChecklistHistoryState() {
|
||||
if (!import.meta.client) return false
|
||||
if (!window.history?.state?.showChecklist) return false
|
||||
|
||||
const state = { ...window.history.state }
|
||||
delete state.showChecklist
|
||||
window.history.replaceState(state, '', window.location.href)
|
||||
return true
|
||||
}
|
||||
|
||||
function setModerationChecklistOpen(open, projectId = project.value?.id) {
|
||||
showModerationChecklist.value = open
|
||||
if (projectId) {
|
||||
void saveChecklistOpenState(projectId, open)
|
||||
}
|
||||
}
|
||||
|
||||
function isProjectInActiveModerationQueue(projectId = project.value?.id) {
|
||||
return (
|
||||
!!projectId &&
|
||||
moderationQueue.isQueueMode &&
|
||||
moderationQueue.currentQueue.items.includes(projectId)
|
||||
)
|
||||
}
|
||||
|
||||
async function openModerationChecklistFromMenu() {
|
||||
const projectId = project.value?.id
|
||||
if (!projectId) return
|
||||
|
||||
await moderationQueue.ready
|
||||
if (!isProjectInActiveModerationQueue(projectId)) {
|
||||
await moderationQueue.setSingleProject(projectId)
|
||||
}
|
||||
|
||||
setModerationChecklistOpen(true)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => project.value?.id,
|
||||
async (projectId, _previousProjectId, onCleanup) => {
|
||||
if (!import.meta.client || !projectId) return
|
||||
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
|
||||
const openedFromNavigation = consumeShowChecklistHistoryState()
|
||||
await moderationQueue.ready
|
||||
if (cancelled) return
|
||||
|
||||
if (openedFromNavigation) {
|
||||
setModerationChecklistOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
const storedOpen = await loadChecklistOpenState(projectId)
|
||||
if (cancelled) return
|
||||
|
||||
if (storedOpen !== null) {
|
||||
showModerationChecklist.value = storedOpen
|
||||
return
|
||||
}
|
||||
|
||||
const shouldRecoverFromQueue =
|
||||
moderationQueue.isQueueMode && moderationQueue.getCurrentProjectId() === projectId
|
||||
showModerationChecklist.value = shouldRecoverFromQueue
|
||||
|
||||
if (shouldRecoverFromQueue) {
|
||||
void saveChecklistOpenState(projectId, true)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function closeDownloadModal(event) {
|
||||
downloadModal.value.hide(event)
|
||||
userSelectedPlatform.value = null
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
:set-status="setStatus"
|
||||
:current-member="currentMember"
|
||||
:auth="auth"
|
||||
@update-thread="(newThread) => (thread = newThread)"
|
||||
@update-thread="updateThread"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
@@ -132,6 +132,13 @@ const { data: thread } = useQuery({
|
||||
enabled: computed(() => !!project.value?.thread_id),
|
||||
})
|
||||
|
||||
function updateThread(newThread) {
|
||||
const threadId = newThread?.id ?? project.value?.thread_id
|
||||
if (!threadId) return
|
||||
|
||||
queryClient.setQueryData(['thread', threadId], newThread)
|
||||
}
|
||||
|
||||
async function setStatus(status) {
|
||||
startLoading()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { CopyIcon, LibraryIcon, PlayIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, Card, StyledInput } from '@modrinth/ui'
|
||||
import { ButtonStyled, Card, NewModal, StyledInput } from '@modrinth/ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import emails from '~/templates/emails'
|
||||
@@ -14,7 +14,7 @@ const filtered = computed(() =>
|
||||
function openAll() {
|
||||
let offset = 0
|
||||
for (const id of filtered.value) {
|
||||
openPreview(id, offset)
|
||||
openPopupPreview(id, offset)
|
||||
offset++
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,81 @@ function copy(id: string) {
|
||||
navigator.clipboard?.writeText(`/_internal/templates/email/${id}`).catch(() => {})
|
||||
}
|
||||
|
||||
function openPreview(id: string, offset = 0) {
|
||||
const previewModal = ref<{ hide: () => void; show: () => void } | null>(null)
|
||||
const previewTemplate = ref<string | null>(null)
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref<string | null>(null)
|
||||
const previewHtml = ref('')
|
||||
const previewVariables = ref<string[]>([])
|
||||
const variableValues = ref<Record<string, string>>({})
|
||||
|
||||
function extractVariables(html: string): string[] {
|
||||
const tokens = new Set<string>()
|
||||
const regex = /\{([a-zA-Z0-9_.-]+)\}/g
|
||||
let match = regex.exec(html)
|
||||
|
||||
while (match !== null) {
|
||||
tokens.add(match[1])
|
||||
match = regex.exec(html)
|
||||
}
|
||||
|
||||
return [...tokens]
|
||||
}
|
||||
|
||||
const renderedPreview = computed(() => {
|
||||
let html = previewHtml.value
|
||||
|
||||
for (const [key, value] of Object.entries(variableValues.value)) {
|
||||
if (!value) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`\\{${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\}`, 'g')
|
||||
html = html.replace(pattern, value)
|
||||
}
|
||||
|
||||
return html
|
||||
})
|
||||
|
||||
async function openPreview(id: string, event?: MouseEvent) {
|
||||
if (event?.shiftKey) {
|
||||
openPopupPreview(id)
|
||||
return
|
||||
}
|
||||
|
||||
previewTemplate.value = id
|
||||
previewLoading.value = true
|
||||
previewError.value = null
|
||||
previewHtml.value = ''
|
||||
previewVariables.value = []
|
||||
variableValues.value = {}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/_internal/templates/email/${id}`)
|
||||
previewHtml.value = await response.text()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load template ${id}`)
|
||||
}
|
||||
|
||||
const variables = extractVariables(previewHtml.value)
|
||||
previewVariables.value = variables
|
||||
variableValues.value = Object.fromEntries(variables.map((value) => [value, '']))
|
||||
previewModal.value?.show()
|
||||
} catch (error) {
|
||||
previewError.value = 'Failed to load email preview.'
|
||||
console.error(error)
|
||||
previewModal.value?.show()
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
previewModal.value?.hide()
|
||||
}
|
||||
|
||||
function openPopupPreview(id: string, offset = 0) {
|
||||
const width = 600
|
||||
const height = 850
|
||||
const left = window.screenX + (window.outerWidth - width) / 2 + ((offset * 28) % 320)
|
||||
@@ -48,6 +122,69 @@ onMounted(() => {
|
||||
<template>
|
||||
<div class="normal-page no-sidebar">
|
||||
<h1 class="mb-4 text-3xl font-extrabold text-heading">Email templates</h1>
|
||||
<NewModal
|
||||
ref="previewModal"
|
||||
header="Preview email"
|
||||
width="min(92vw, 1000px)"
|
||||
:max-content-height="'88vh'"
|
||||
scrollable
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="label__title text-base">Template: {{ previewTemplate }}</p>
|
||||
|
||||
<div
|
||||
v-if="previewError"
|
||||
class="border-danger bg-danger/10 text-danger my-2 rounded border px-3 py-2 text-sm"
|
||||
>
|
||||
{{ previewError }}
|
||||
</div>
|
||||
|
||||
<div v-if="previewLoading" class="my-4 text-sm text-secondary">Loading preview…</div>
|
||||
<div v-else>
|
||||
<div v-if="previewVariables.length" class="mt-2 grid gap-3 md:grid-cols-2">
|
||||
<label
|
||||
v-for="variable in previewVariables"
|
||||
:key="variable"
|
||||
:for="`preview-${variable}`"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<span class="label__title">{{ variable }}</span>
|
||||
<StyledInput
|
||||
:id="`preview-${variable}`"
|
||||
v-model="variableValues[variable]"
|
||||
type="text"
|
||||
:placeholder="`Enter ${variable}`"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p v-else class="mt-2 text-xs text-secondary">
|
||||
No template variables were detected; preview shown using default values.
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="label__title mb-2">Rendered template</div>
|
||||
<iframe
|
||||
v-if="!previewError"
|
||||
:srcdoc="renderedPreview"
|
||||
class="h-[60vh] w-full rounded border border-divider bg-white"
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="rounded border border-divider bg-white px-4 py-3 text-sm text-secondary"
|
||||
>
|
||||
Could not render template preview.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group mt-4">
|
||||
<button class="iconified-button transparent" type="button" @click="closePreview">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<div class="normal-page__content">
|
||||
<Card class="mb-6 flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
@@ -97,7 +234,7 @@ onMounted(() => {
|
||||
|
||||
<div class="mt-auto flex gap-2">
|
||||
<ButtonStyled color="brand" class="flex-1">
|
||||
<button class="w-full justify-center" @click="openPreview(id)">
|
||||
<button class="w-full justify-center" @click="openPreview(id, $event)">
|
||||
<PlayIcon class="h-4 w-4" aria-hidden="true" />
|
||||
Preview
|
||||
</button>
|
||||
|
||||
@@ -1175,7 +1175,7 @@ useSeoMeta({
|
||||
<span>{{ formatMessage(messages.getItOnFlathub) }}</span>
|
||||
</a>
|
||||
<Accordion
|
||||
class="mt-2 flex flex-col"
|
||||
class="mt-2 flex flex-col items-center"
|
||||
content-class="flex flex-col items-start gap-2 mt-2 text-sm"
|
||||
button-class="text-sm text-secondary bg-transparent p-0 w-fit text-left m-0 active:scale-[0.98] transition-transform"
|
||||
>
|
||||
@@ -2186,10 +2186,6 @@ useSeoMeta({
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
&.apple {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.animated-dropdown) {
|
||||
|
||||
@@ -3,26 +3,47 @@
|
||||
<div class="normal-page__sidebar">
|
||||
<NavStack
|
||||
:items="[
|
||||
{ type: 'heading', label: 'Dashboard' },
|
||||
{ link: '/dashboard', label: 'Overview', icon: DashboardIcon },
|
||||
{ link: '/dashboard/notifications', label: 'Notifications', icon: NotificationsIcon },
|
||||
{ link: '/dashboard/reports', label: 'Active reports', icon: ReportIcon },
|
||||
{ type: 'heading', label: formatMessage(messages.dashboard) },
|
||||
{ link: '/dashboard', label: formatMessage(messages.overview), icon: DashboardIcon },
|
||||
{
|
||||
link: '/dashboard/notifications',
|
||||
label: formatMessage(messages.notifications),
|
||||
icon: NotificationsIcon,
|
||||
},
|
||||
{
|
||||
link: '/dashboard/reports',
|
||||
label: formatMessage(messages.activeReports),
|
||||
icon: ReportIcon,
|
||||
},
|
||||
{
|
||||
link: '/dashboard/collections',
|
||||
label: formatMessage(commonMessages.collectionsLabel),
|
||||
icon: LibraryIcon,
|
||||
},
|
||||
{ type: 'heading', label: 'Creators' },
|
||||
{ link: '/dashboard/projects', label: 'Projects', icon: ListIcon },
|
||||
{ link: '/dashboard/organizations', label: 'Organizations', icon: OrganizationIcon },
|
||||
{ link: '/dashboard/analytics', label: 'Analytics', icon: ChartIcon },
|
||||
{ type: 'heading', label: formatMessage(messages.creators) },
|
||||
{ link: '/dashboard/projects', label: formatMessage(messages.projects), icon: ListIcon },
|
||||
{
|
||||
link: '/dashboard/organizations',
|
||||
label: formatMessage(messages.organizations),
|
||||
icon: OrganizationIcon,
|
||||
},
|
||||
{
|
||||
link: '/dashboard/analytics',
|
||||
label: formatMessage(messages.analytics),
|
||||
icon: ChartIcon,
|
||||
},
|
||||
{
|
||||
link: '/dashboard/affiliate-links',
|
||||
label: formatMessage(commonMessages.affiliateLinksButton),
|
||||
icon: AffiliateIcon,
|
||||
shown: !!isAffiliate,
|
||||
},
|
||||
{ link: '/dashboard/revenue', label: 'Revenue', icon: CurrencyIcon, matchNested: true },
|
||||
{
|
||||
link: '/dashboard/revenue',
|
||||
label: formatMessage(messages.revenue),
|
||||
icon: CurrencyIcon,
|
||||
matchNested: true,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
@@ -43,7 +64,7 @@ import {
|
||||
OrganizationIcon,
|
||||
ReportIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { commonMessages, useVIntl } from '@modrinth/ui'
|
||||
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { type User, UserBadge } from '@modrinth/utils'
|
||||
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
@@ -56,6 +77,45 @@ const isAffiliate = computed(() => {
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
dashboard: {
|
||||
id: 'dashboard.sidebar.label.dashboard',
|
||||
defaultMessage: 'Dashboard',
|
||||
},
|
||||
overview: {
|
||||
id: 'dashboard.sidebar.label.overview',
|
||||
defaultMessage: 'Overview',
|
||||
},
|
||||
notifications: {
|
||||
id: 'dashboard.sidebar.label.notifications',
|
||||
defaultMessage: 'Notifications',
|
||||
},
|
||||
activeReports: {
|
||||
id: 'dashboard.sidebar.label.activeReports',
|
||||
defaultMessage: 'Active reports',
|
||||
},
|
||||
creators: {
|
||||
id: 'dashboard.sidebar.label.creators',
|
||||
defaultMessage: 'Creators',
|
||||
},
|
||||
projects: {
|
||||
id: 'dashboard.sidebar.label.projects',
|
||||
defaultMessage: 'Projects',
|
||||
},
|
||||
organizations: {
|
||||
id: 'dashboard.sidebar.label.organizations',
|
||||
defaultMessage: 'Organizations',
|
||||
},
|
||||
analytics: {
|
||||
id: 'dashboard.sidebar.label.analytics',
|
||||
defaultMessage: 'Analytics',
|
||||
},
|
||||
revenue: {
|
||||
id: 'dashboard.sidebar.label.revenue',
|
||||
defaultMessage: 'Revenue',
|
||||
},
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
v-else-if="!filteredAffiliates || filteredAffiliates.length === 0"
|
||||
class="py-8 text-center"
|
||||
>
|
||||
<p class="text-secondary">No affiliate codes found.</p>
|
||||
<p class="text-secondary">{{ formatMessage(messages.noAffiliateCodesFound) }}</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<AffiliateLinkCard
|
||||
@@ -166,6 +166,10 @@ const messages = defineMessages({
|
||||
id: 'dashboard.affiliate-links.error.title',
|
||||
defaultMessage: 'Error loading affiliate links',
|
||||
},
|
||||
noAffiliateCodesFound: {
|
||||
id: 'dashboard.affiliate-links.empty.no-codes',
|
||||
defaultMessage: 'No affiliate codes found.',
|
||||
},
|
||||
revokeConfirmButton: {
|
||||
id: 'dashboard.affiliate-links.revoke-confirm.button',
|
||||
defaultMessage: 'Revoke',
|
||||
|
||||
@@ -12,10 +12,17 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { injectModrinthClient, useDebugLogger } from '@modrinth/ui'
|
||||
import {
|
||||
commonProjectSettingsMessages,
|
||||
injectModrinthClient,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const debug = useDebugLogger('analytics.vue')
|
||||
|
||||
definePageMeta({
|
||||
@@ -23,7 +30,7 @@ definePageMeta({
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Analytics - Modrinth',
|
||||
title: () => `${formatMessage(commonProjectSettingsMessages.analytics)} - Modrinth`,
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
clearable
|
||||
placeholder="Search collections..."
|
||||
:placeholder="formatMessage(messages.searchCollectionsPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
input-class="!h-12"
|
||||
/>
|
||||
@@ -20,18 +20,13 @@
|
||||
v-slot="{ selected }"
|
||||
v-model="sortBy"
|
||||
class="!w-auto flex-grow md:flex-grow-0"
|
||||
name="Sort by"
|
||||
:name="formatMessage(commonMessages.sortByLabel)"
|
||||
:options="['updated', 'created', 'name']"
|
||||
:display-name="
|
||||
(option) =>
|
||||
option === 'updated'
|
||||
? 'Recently Updated'
|
||||
: option === 'created'
|
||||
? 'Recently Created'
|
||||
: 'Name (A-Z)'
|
||||
"
|
||||
:display-name="formatCollectionSortOption"
|
||||
>
|
||||
<span class="font-semibold text-primary">Sort by: </span>
|
||||
<span class="font-semibold text-primary">{{
|
||||
formatMessage(commonMessages.sortByLabel)
|
||||
}}</span>
|
||||
<span class="font-semibold text-secondary">{{ selected }}</span>
|
||||
</DropdownSelect>
|
||||
|
||||
@@ -188,6 +183,22 @@ const messages = defineMessages({
|
||||
id: 'dashboard.collections.label.search-input',
|
||||
defaultMessage: 'Search your collections',
|
||||
},
|
||||
searchCollectionsPlaceholder: {
|
||||
id: 'dashboard.collections.placeholder.search',
|
||||
defaultMessage: 'Search collections...',
|
||||
},
|
||||
sortRecentlyUpdated: {
|
||||
id: 'dashboard.collections.sort.recently-updated',
|
||||
defaultMessage: 'Recently Updated',
|
||||
},
|
||||
sortRecentlyCreated: {
|
||||
id: 'dashboard.collections.sort.recently-created',
|
||||
defaultMessage: 'Recently Created',
|
||||
},
|
||||
sortNameAscending: {
|
||||
id: 'dashboard.collections.sort.name-ascending',
|
||||
defaultMessage: 'Name (A-Z)',
|
||||
},
|
||||
emptyNoMatch: {
|
||||
id: 'dashboard.collections.empty.no-match',
|
||||
defaultMessage: 'No collections match your search',
|
||||
@@ -234,6 +245,18 @@ const router = useNativeRouter()
|
||||
const validSortOptions = ['updated', 'created', 'name']
|
||||
const sortBy = ref(validSortOptions.includes(route.query.s) ? route.query.s : 'updated')
|
||||
|
||||
function formatCollectionSortOption(option) {
|
||||
if (option === 'updated') {
|
||||
return formatMessage(messages.sortRecentlyUpdated)
|
||||
}
|
||||
|
||||
if (option === 'created') {
|
||||
return formatMessage(messages.sortRecentlyCreated)
|
||||
}
|
||||
|
||||
return formatMessage(messages.sortNameAscending)
|
||||
}
|
||||
|
||||
const orderedCollections = computed(() => {
|
||||
if (!collections.value) return []
|
||||
return [...collections.value]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{{ auth.user.username }}
|
||||
</h1>
|
||||
<NuxtLink class="goto-link" :to="`/user/${auth.user.username}`">
|
||||
Visit your profile
|
||||
{{ formatMessage(commonMessages.visitYourProfile) }}
|
||||
<ChevronRightIcon class="featured-header-chevron" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
@@ -15,13 +15,15 @@
|
||||
<div class="dashboard-notifications">
|
||||
<section class="universal-card">
|
||||
<div class="header__row">
|
||||
<h2 class="header__title text-2xl">Notifications</h2>
|
||||
<h2 class="header__title text-2xl">
|
||||
{{ formatMessage(commonMessages.notificationsLabel) }}
|
||||
</h2>
|
||||
<nuxt-link
|
||||
v-if="notifications.length > 0"
|
||||
class="goto-link"
|
||||
to="/dashboard/notifications"
|
||||
>
|
||||
See all
|
||||
{{ formatMessage(messages.seeAll) }}
|
||||
<ChevronRightIcon />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
@@ -42,15 +44,15 @@
|
||||
class="goto-link view-more-notifs mt-4"
|
||||
to="/dashboard/notifications"
|
||||
>
|
||||
View {{ extraNotifs }} more notification{{ extraNotifs === 1 ? '' : 's' }}
|
||||
{{ formatMessage(messages.viewMore, { extraNotifs: extraNotifs }) }}
|
||||
<ChevronRightIcon />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
<div v-else class="universal-body">
|
||||
<p>You have no unread notifications.</p>
|
||||
<p>{{ formatMessage(messages.noUnreadNotifications) }}</p>
|
||||
<nuxt-link class="iconified-button !mt-4" to="/dashboard/notifications/history">
|
||||
<HistoryIcon />
|
||||
View notification history
|
||||
{{ formatMessage(messages.viewNotificationHistory) }}
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</section>
|
||||
@@ -58,18 +60,16 @@
|
||||
|
||||
<div class="dashboard-analytics">
|
||||
<section class="universal-card">
|
||||
<h2>Analytics</h2>
|
||||
<h2>{{ formatMessage(commonMessages.analyticsButton) }}</h2>
|
||||
<div class="grid-display">
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Total downloads</div>
|
||||
<div class="label">{{ formatMessage(messages.totalDownloads) }}</div>
|
||||
<div class="value">
|
||||
{{ $formatNumber(projects.reduce((agg, x) => agg + x.downloads, 0)) }}
|
||||
</div>
|
||||
<span
|
||||
>from
|
||||
{{ downloadsProjectCount }}
|
||||
project{{ downloadsProjectCount === 1 ? '' : 's' }}</span
|
||||
>
|
||||
<span>{{
|
||||
formatMessage(messages.fromProjects, { count: downloadsProjectCount })
|
||||
}}</span>
|
||||
<!-- <NuxtLink class="goto-link" to="/dashboard/analytics"-->
|
||||
<!-- >View breakdown-->
|
||||
<!-- <ChevronRightIcon-->
|
||||
@@ -78,17 +78,15 @@
|
||||
<!-- /></NuxtLink>-->
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Total followers</div>
|
||||
<div class="label">{{ formatMessage(messages.totalFollowers) }}</div>
|
||||
<div class="value">
|
||||
{{ $formatNumber(projects.reduce((agg, x) => agg + x.followers, 0)) }}
|
||||
</div>
|
||||
<span>
|
||||
<span
|
||||
>from {{ followersProjectCount }} project{{
|
||||
followersProjectCount === 1 ? '' : 's'
|
||||
}}</span
|
||||
></span
|
||||
>
|
||||
<span>{{
|
||||
formatMessage(messages.fromProjects, { count: followersProjectCount })
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -97,14 +95,58 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { ChevronRightIcon, HistoryIcon } from '@modrinth/assets'
|
||||
import { Avatar, injectModrinthClient } from '@modrinth/ui'
|
||||
import {
|
||||
Avatar,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
|
||||
import NotificationItem from '~/components/ui/NotificationItem.vue'
|
||||
import { fetchExtraNotificationData, groupNotifications } from '~/helpers/platform-notifications.ts'
|
||||
|
||||
useHead({
|
||||
title: 'Dashboard - Modrinth',
|
||||
title: () => `${formatMessage(messages.headTitle)} - Modrinth`,
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
headTitle: {
|
||||
id: 'dashboard.head-title',
|
||||
defaultMessage: 'Dashboard',
|
||||
},
|
||||
seeAll: {
|
||||
id: 'dashboard.notifications.link.see-all',
|
||||
defaultMessage: 'See all',
|
||||
},
|
||||
viewMore: {
|
||||
id: 'dashboard.notifications.link.view-more',
|
||||
defaultMessage:
|
||||
'View {extraNotifs} more {extraNotifs, plural, one {notification} other {notifications}}',
|
||||
},
|
||||
noUnreadNotifications: {
|
||||
id: 'dashboard.notifications.empty.no-unread',
|
||||
defaultMessage: 'You have no unread notifications.',
|
||||
},
|
||||
viewNotificationHistory: {
|
||||
id: 'dashboard.notifications.link.view-history',
|
||||
defaultMessage: 'View notification history',
|
||||
},
|
||||
totalDownloads: {
|
||||
id: 'dashboard.analytics.total-downloads',
|
||||
defaultMessage: 'Total downloads',
|
||||
},
|
||||
totalFollowers: {
|
||||
id: 'dashboard.analytics.total-followers',
|
||||
defaultMessage: 'Total followers',
|
||||
},
|
||||
fromProjects: {
|
||||
id: 'dashboard.analytics.from-projects',
|
||||
defaultMessage: 'from {count} {count, plural, one {project} other {projects}}',
|
||||
},
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
@@ -3,22 +3,31 @@
|
||||
<section class="universal-card">
|
||||
<Breadcrumbs
|
||||
v-if="history"
|
||||
current-title="History"
|
||||
:link-stack="[{ href: `/dashboard/notifications`, label: 'Notifications' }]"
|
||||
:current-title="formatMessage(messages.historyLabel)"
|
||||
:link-stack="[
|
||||
{
|
||||
href: `/dashboard/notifications`,
|
||||
label: formatMessage(commonMessages.notificationsLabel),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div class="header__row">
|
||||
<div class="header__title">
|
||||
<h2 v-if="history" class="text-2xl">Notification history</h2>
|
||||
<h2 v-else class="text-2xl">Notifications</h2>
|
||||
<h2 v-if="history" class="text-2xl">
|
||||
{{ formatMessage(messages.notificationHistoryTitle) }}
|
||||
</h2>
|
||||
<h2 v-else class="text-2xl">
|
||||
{{ formatMessage(commonMessages.notificationsLabel) }}
|
||||
</h2>
|
||||
</div>
|
||||
<template v-if="!history">
|
||||
<Button v-if="data.hasRead" @click="updateRoute()">
|
||||
<HistoryIcon />
|
||||
View history
|
||||
{{ formatMessage(messages.viewHistory) }}
|
||||
</Button>
|
||||
<Button v-if="notifications.length > 0" color="danger" @click="readAll()">
|
||||
<CheckCheckIcon />
|
||||
Mark all as read
|
||||
{{ formatMessage(messages.markAllAsRead) }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
@@ -29,9 +38,9 @@
|
||||
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x).replace('_', ' ') + 's')"
|
||||
:capitalize="false"
|
||||
/>
|
||||
<p v-if="isPending">Loading notifications...</p>
|
||||
<p v-if="isPending">{{ formatMessage(messages.loadingNotifications) }}</p>
|
||||
<template v-else-if="error">
|
||||
<p>Error loading notifications:</p>
|
||||
<p>{{ formatMessage(messages.errorLoadingNotifications) }}</p>
|
||||
<pre>
|
||||
{{ error }}
|
||||
</pre>
|
||||
@@ -48,7 +57,7 @@
|
||||
@update:notifications="() => refetch()"
|
||||
/>
|
||||
</template>
|
||||
<p v-else>You don't have any unread notifications.</p>
|
||||
<p v-else>{{ formatMessage(messages.noUnreadNotifications) }}</p>
|
||||
<div class="flex justify-end">
|
||||
<Pagination :page="page" :count="pages" @switch-page="changePage" />
|
||||
</div>
|
||||
@@ -57,7 +66,15 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { CheckCheckIcon, HistoryIcon } from '@modrinth/assets'
|
||||
import { Button, Chips, injectModrinthClient, Pagination } from '@modrinth/ui'
|
||||
import {
|
||||
Button,
|
||||
Chips,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
Pagination,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { formatProjectType } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
|
||||
@@ -69,8 +86,37 @@ import {
|
||||
markAsRead,
|
||||
} from '~/helpers/platform-notifications.ts'
|
||||
|
||||
useHead({
|
||||
title: 'Notifications - Modrinth',
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
historyLabel: {
|
||||
id: 'dashboard.notifications.history.label',
|
||||
defaultMessage: 'History',
|
||||
},
|
||||
notificationHistoryTitle: {
|
||||
id: 'dashboard.notifications.history.title',
|
||||
defaultMessage: 'Notification history',
|
||||
},
|
||||
viewHistory: {
|
||||
id: 'dashboard.notifications.button.view-history',
|
||||
defaultMessage: 'View history',
|
||||
},
|
||||
markAllAsRead: {
|
||||
id: 'dashboard.notifications.button.mark-all-as-read',
|
||||
defaultMessage: 'Mark all as read',
|
||||
},
|
||||
loadingNotifications: {
|
||||
id: 'dashboard.notifications.loading',
|
||||
defaultMessage: 'Loading notifications...',
|
||||
},
|
||||
errorLoadingNotifications: {
|
||||
id: 'dashboard.notifications.error.loading',
|
||||
defaultMessage: 'Error loading notifications:',
|
||||
},
|
||||
noUnreadNotifications: {
|
||||
id: 'dashboard.notifications.empty.no-unread',
|
||||
defaultMessage: "You don't have any unread notifications.",
|
||||
},
|
||||
})
|
||||
|
||||
const client = injectModrinthClient()
|
||||
@@ -79,6 +125,12 @@ const route = useNativeRoute()
|
||||
const router = useNativeRouter()
|
||||
|
||||
const history = computed(() => route.name === 'dashboard-notifications-history')
|
||||
|
||||
useHead({
|
||||
title: () =>
|
||||
`${formatMessage(history.value ? messages.notificationHistoryTitle : commonMessages.notificationsLabel)} - Modrinth`,
|
||||
})
|
||||
|
||||
const selectedType = ref('all')
|
||||
const page = ref(1)
|
||||
const perPage = ref(50)
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
<OrganizationCreateModal ref="createOrgModal" />
|
||||
<section class="universal-card">
|
||||
<div class="header__row">
|
||||
<h2 class="header__title text-2xl">Organizations</h2>
|
||||
<h2 class="header__title text-2xl">{{ formatMessage(messages.organizationsTitle) }}</h2>
|
||||
<div class="input-group">
|
||||
<button class="iconified-button brand-button" @click="openCreateOrgModal">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Create organization
|
||||
{{ formatMessage(messages.createOrganization) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,10 +32,11 @@
|
||||
<div class="stats">
|
||||
<UsersIcon aria-hidden="true" />
|
||||
<span>
|
||||
{{ onlyAcceptedMembers(org.members).length }}
|
||||
member<template v-if="onlyAcceptedMembers(org.members).length !== 1"
|
||||
>s</template
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.memberCount, {
|
||||
count: onlyAcceptedMembers(org.members).length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
@@ -43,19 +44,44 @@
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else> Make an organization! </template>
|
||||
<template v-else> {{ formatMessage(messages.makeOrganization) }} </template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { PlusIcon, UsersIcon } from '@modrinth/assets'
|
||||
import { Avatar, injectModrinthClient } from '@modrinth/ui'
|
||||
import { Avatar, defineMessages, injectModrinthClient, useVIntl } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
|
||||
import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue'
|
||||
import { useAuth } from '~/composables/auth.js'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
organizationsTitle: {
|
||||
id: 'dashboard.organizations.title',
|
||||
defaultMessage: 'Organizations',
|
||||
},
|
||||
createOrganization: {
|
||||
id: 'dashboard.organizations.button.create',
|
||||
defaultMessage: 'Create organization',
|
||||
},
|
||||
memberCount: {
|
||||
id: 'dashboard.organizations.member-count',
|
||||
defaultMessage: '{count} {count, plural, one {member} other {members}}',
|
||||
},
|
||||
makeOrganization: {
|
||||
id: 'dashboard.organizations.empty.cta',
|
||||
defaultMessage: 'Make an organization!',
|
||||
},
|
||||
fetchOrganizationsFailed: {
|
||||
id: 'dashboard.organizations.error.fetch',
|
||||
defaultMessage: 'Failed to fetch organizations',
|
||||
},
|
||||
})
|
||||
|
||||
const createOrgModal = ref(null)
|
||||
|
||||
const auth = await useAuth()
|
||||
@@ -77,7 +103,7 @@ const onlyAcceptedMembers = (members) => members.filter((member) => member?.acce
|
||||
if (error.value) {
|
||||
createError({
|
||||
statusCode: 500,
|
||||
message: 'Failed to fetch organizations',
|
||||
message: formatMessage(messages.fetchOrganizationsFailed),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
<NewModal ref="editLinksModal" header="Edit links">
|
||||
<NewModal ref="editLinksModal" :header="formatMessage(messages.editLinksButton)">
|
||||
<div class="universal-modal links-modal !p-0">
|
||||
<p>
|
||||
Any links you specify below will be overwritten on each of the selected projects. Any you
|
||||
leave blank will be ignored. You can clear a link from all selected projects using the
|
||||
trash can button.
|
||||
</p>
|
||||
<p>{{ formatMessage(messages.editLinksDescription) }}</p>
|
||||
<section class="links">
|
||||
<label
|
||||
for="issue-tracker-input"
|
||||
title="A place for users to report bugs, issues, and concerns about your project."
|
||||
>
|
||||
<span class="label__title">Issue tracker</span>
|
||||
<label for="issue-tracker-input" :title="formatMessage(messages.issueTrackerDescription)">
|
||||
<span class="label__title">{{ formatMessage(messages.issueTrackerLabel) }}</span>
|
||||
</label>
|
||||
<div class="input-group shrink-first">
|
||||
<StyledInput
|
||||
@@ -20,14 +13,12 @@
|
||||
v-model="editLinks.issues.val"
|
||||
:disabled="editLinks.issues.clear"
|
||||
type="url"
|
||||
:placeholder="
|
||||
editLinks.issues.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
|
||||
"
|
||||
:placeholder="getLinkInputPlaceholder(editLinks.issues.clear)"
|
||||
:maxlength="2048"
|
||||
/>
|
||||
<button
|
||||
v-tooltip="'Clear link'"
|
||||
aria-label="Clear link"
|
||||
v-tooltip="formatMessage(messages.clearLinkLabel)"
|
||||
:aria-label="formatMessage(messages.clearLinkLabel)"
|
||||
class="square-button label-button"
|
||||
:data-active="editLinks.issues.clear"
|
||||
@click="editLinks.issues.clear = !editLinks.issues.clear"
|
||||
@@ -35,11 +26,8 @@
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
<label
|
||||
for="source-code-input"
|
||||
title="A page/repository containing the source code for your project"
|
||||
>
|
||||
<span class="label__title">Source code</span>
|
||||
<label for="source-code-input" :title="formatMessage(messages.sourceCodeDescription)">
|
||||
<span class="label__title">{{ formatMessage(messages.sourceCodeLabel) }}</span>
|
||||
</label>
|
||||
<div class="input-group shrink-first">
|
||||
<StyledInput
|
||||
@@ -48,13 +36,11 @@
|
||||
:disabled="editLinks.source.clear"
|
||||
type="url"
|
||||
:maxlength="2048"
|
||||
:placeholder="
|
||||
editLinks.source.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
|
||||
"
|
||||
:placeholder="getLinkInputPlaceholder(editLinks.source.clear)"
|
||||
/>
|
||||
<button
|
||||
v-tooltip="'Clear link'"
|
||||
aria-label="Clear link"
|
||||
v-tooltip="formatMessage(messages.clearLinkLabel)"
|
||||
:aria-label="formatMessage(messages.clearLinkLabel)"
|
||||
class="square-button label-button"
|
||||
:data-active="editLinks.source.clear"
|
||||
@click="editLinks.source.clear = !editLinks.source.clear"
|
||||
@@ -62,11 +48,8 @@
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
<label
|
||||
for="wiki-page-input"
|
||||
title="A page containing information, documentation, and help for the project."
|
||||
>
|
||||
<span class="label__title">Wiki page</span>
|
||||
<label for="wiki-page-input" :title="formatMessage(messages.wikiPageDescription)">
|
||||
<span class="label__title">{{ formatMessage(messages.wikiPageLabel) }}</span>
|
||||
</label>
|
||||
<div class="input-group shrink-first">
|
||||
<StyledInput
|
||||
@@ -75,13 +58,11 @@
|
||||
:disabled="editLinks.wiki.clear"
|
||||
type="url"
|
||||
:maxlength="2048"
|
||||
:placeholder="
|
||||
editLinks.wiki.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
|
||||
"
|
||||
:placeholder="getLinkInputPlaceholder(editLinks.wiki.clear)"
|
||||
/>
|
||||
<button
|
||||
v-tooltip="'Clear link'"
|
||||
aria-label="Clear link"
|
||||
v-tooltip="formatMessage(messages.clearLinkLabel)"
|
||||
:aria-label="formatMessage(messages.clearLinkLabel)"
|
||||
class="square-button label-button"
|
||||
:data-active="editLinks.wiki.clear"
|
||||
@click="editLinks.wiki.clear = !editLinks.wiki.clear"
|
||||
@@ -89,8 +70,11 @@
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
<label for="discord-invite-input" title="An invitation link to your Discord server.">
|
||||
<span class="label__title">Discord invite</span>
|
||||
<label
|
||||
for="discord-invite-input"
|
||||
:title="formatMessage(messages.discordInviteDescription)"
|
||||
>
|
||||
<span class="label__title">{{ formatMessage(messages.discordInviteLabel) }}</span>
|
||||
</label>
|
||||
<div class="input-group shrink-first">
|
||||
<StyledInput
|
||||
@@ -99,15 +83,11 @@
|
||||
:disabled="editLinks.discord.clear"
|
||||
type="url"
|
||||
:maxlength="2048"
|
||||
:placeholder="
|
||||
editLinks.discord.clear
|
||||
? 'Existing link will be cleared'
|
||||
: 'Enter a valid Discord invite URL'
|
||||
"
|
||||
:placeholder="getLinkInputPlaceholder(editLinks.discord.clear, true)"
|
||||
/>
|
||||
<button
|
||||
v-tooltip="'Clear link'"
|
||||
aria-label="Clear link"
|
||||
v-tooltip="formatMessage(messages.clearLinkLabel)"
|
||||
:aria-label="formatMessage(messages.clearLinkLabel)"
|
||||
class="square-button label-button"
|
||||
:data-active="editLinks.discord.clear"
|
||||
@click="editLinks.discord.clear = !editLinks.discord.clear"
|
||||
@@ -117,10 +97,14 @@
|
||||
</div>
|
||||
</section>
|
||||
<p>
|
||||
Changes will be applied to
|
||||
<strong>{{ selectedProjects.length }}</strong> project{{
|
||||
selectedProjects.length > 1 ? 's' : ''
|
||||
}}.
|
||||
<IntlFormatted
|
||||
:message-id="messages.changesAppliedTo"
|
||||
:values="{ count: selectedProjects.length }"
|
||||
>
|
||||
<template #strong="{ children }">
|
||||
<strong><component :is="() => children" /></strong>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<ul>
|
||||
<li
|
||||
@@ -133,23 +117,25 @@
|
||||
{{ project.title }}
|
||||
</li>
|
||||
<li v-if="!editLinks.showAffected && selectedProjects.length > 3">
|
||||
<strong>and {{ selectedProjects.length - 3 }} more...</strong>
|
||||
<strong>{{
|
||||
formatMessage(messages.andMore, { count: selectedProjects.length - 3 })
|
||||
}}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<Checkbox
|
||||
v-if="selectedProjects.length > 3"
|
||||
v-model="editLinks.showAffected"
|
||||
label="Show all projects"
|
||||
description="Show all projects"
|
||||
:label="formatMessage(messages.showAllProjects)"
|
||||
:description="formatMessage(messages.showAllProjects)"
|
||||
/>
|
||||
<div class="push-right input-group">
|
||||
<button class="iconified-button" @click="$refs.editLinksModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
<button class="iconified-button brand-button" @click="bulkEditLinks()">
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +143,7 @@
|
||||
<ModalCreation ref="modal_creation" />
|
||||
<section class="universal-card">
|
||||
<div class="header__row">
|
||||
<h2 class="header__title text-2xl">Projects</h2>
|
||||
<h2 class="header__title text-2xl">{{ formatMessage(messages.headTitle) }}</h2>
|
||||
<div class="input-group">
|
||||
<button class="iconified-button brand-button" @click="$refs.modal_creation.show($event)">
|
||||
<PlusIcon />
|
||||
@@ -166,10 +152,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="projects.length < 1">
|
||||
You don't have any projects yet. Click the green button above to begin.
|
||||
{{ formatMessage(messages.noProjectsYet) }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<p>You can edit multiple projects at once by selecting them below.</p>
|
||||
<p>{{ formatMessage(messages.bulkEditHint) }}</p>
|
||||
<div class="input-group">
|
||||
<button
|
||||
class="iconified-button"
|
||||
@@ -177,11 +163,11 @@
|
||||
@click="$refs.editLinksModal.show()"
|
||||
>
|
||||
<EditIcon />
|
||||
Edit links
|
||||
{{ formatMessage(messages.editLinksButton) }}
|
||||
</button>
|
||||
<div class="push-right">
|
||||
<div class="labeled-control-row">
|
||||
Sort by
|
||||
{{ formatMessage(commonMessages.sortByLabel) }}
|
||||
<Combobox
|
||||
v-model="sortBy"
|
||||
:searchable="false"
|
||||
@@ -190,7 +176,7 @@
|
||||
@update:model-value="projects = updateSort(projects, sortBy, descending)"
|
||||
/>
|
||||
<button
|
||||
v-tooltip="descending ? 'Descending' : 'Ascending'"
|
||||
v-tooltip="formatMessage(descending ? messages.descending : messages.ascending)"
|
||||
class="square-button"
|
||||
@click="updateDescending()"
|
||||
>
|
||||
@@ -208,11 +194,11 @@
|
||||
@update:model-value="toggleAllBulkEditableProjects()"
|
||||
/>
|
||||
</div>
|
||||
<div>Icon</div>
|
||||
<div>Name</div>
|
||||
<div>ID</div>
|
||||
<div>Type</div>
|
||||
<div>Status</div>
|
||||
<div>{{ formatMessage(messages.iconHeader) }}</div>
|
||||
<div>{{ formatMessage(messages.nameHeader) }}</div>
|
||||
<div>{{ formatMessage(messages.idHeader) }}</div>
|
||||
<div>{{ formatMessage(messages.typeHeader) }}</div>
|
||||
<div>{{ formatMessage(messages.statusHeader) }}</div>
|
||||
<div />
|
||||
</div>
|
||||
<div v-for="project in projects" :key="`project-${project.id}`" class="grid-table__row">
|
||||
@@ -234,7 +220,7 @@
|
||||
<Avatar
|
||||
:src="project.icon_url"
|
||||
aria-hidden="true"
|
||||
:alt="'Icon for ' + project.title"
|
||||
:alt="formatMessage(messages.projectIconAlt, { title: project.title })"
|
||||
no-shadow
|
||||
/>
|
||||
</nuxt-link>
|
||||
@@ -244,7 +230,7 @@
|
||||
<span class="project-title">
|
||||
<IssuesIcon
|
||||
v-if="project.moderator_message"
|
||||
aria-label="Project has a message from the moderators. View the project to see more."
|
||||
:aria-label="formatMessage(messages.projectModeratorMessageAriaLabel)"
|
||||
/>
|
||||
|
||||
<nuxt-link
|
||||
@@ -277,7 +263,7 @@
|
||||
color="orange"
|
||||
>
|
||||
<nuxt-link
|
||||
v-tooltip="'Please review environment metadata'"
|
||||
v-tooltip="formatMessage(messages.reviewEnvironmentMetadata)"
|
||||
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}?showEnvironmentMigrationWarning=true`"
|
||||
@@ -323,7 +309,9 @@ import {
|
||||
Combobox,
|
||||
commonMessages,
|
||||
CopyCode,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
NewModal,
|
||||
ProjectStatusBadge,
|
||||
StyledInput,
|
||||
@@ -334,8 +322,6 @@ import { formatProjectType } from '@modrinth/utils'
|
||||
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
|
||||
import { getProjectTypeForUrl } from '~/helpers/projects.js'
|
||||
|
||||
useHead({ title: 'Projects - Modrinth' })
|
||||
|
||||
// const UPLOAD_VERSION = 1 << 0
|
||||
// const DELETE_VERSION = 1 << 1
|
||||
const EDIT_DETAILS = 1 << 2
|
||||
@@ -348,16 +334,163 @@ const EDIT_DETAILS = 1 << 2
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
headTitle: {
|
||||
id: 'dashboard.projects.head-title',
|
||||
defaultMessage: 'Projects',
|
||||
},
|
||||
editLinksButton: {
|
||||
id: 'dashboard.projects.links.button.edit',
|
||||
defaultMessage: 'Edit links',
|
||||
},
|
||||
editLinksDescription: {
|
||||
id: 'dashboard.projects.links.description',
|
||||
defaultMessage:
|
||||
'Any links you specify below will be overwritten on each of the selected projects. Any you leave blank will be ignored. You can clear a link from all selected projects using the trash can button.',
|
||||
},
|
||||
issueTrackerLabel: {
|
||||
id: 'dashboard.projects.links.issue-tracker.label',
|
||||
defaultMessage: 'Issue tracker',
|
||||
},
|
||||
issueTrackerDescription: {
|
||||
id: 'dashboard.projects.links.issue-tracker.description',
|
||||
defaultMessage: 'A place for users to report bugs, issues, and concerns about your project.',
|
||||
},
|
||||
sourceCodeLabel: {
|
||||
id: 'dashboard.projects.links.source-code.label',
|
||||
defaultMessage: 'Source code',
|
||||
},
|
||||
sourceCodeDescription: {
|
||||
id: 'dashboard.projects.links.source-code.description',
|
||||
defaultMessage: 'A page/repository containing the source code for your project',
|
||||
},
|
||||
wikiPageLabel: {
|
||||
id: 'dashboard.projects.links.wiki-page.label',
|
||||
defaultMessage: 'Wiki page',
|
||||
},
|
||||
wikiPageDescription: {
|
||||
id: 'dashboard.projects.links.wiki-page.description',
|
||||
defaultMessage: 'A page containing information, documentation, and help for the project.',
|
||||
},
|
||||
discordInviteLabel: {
|
||||
id: 'dashboard.projects.links.discord-invite.label',
|
||||
defaultMessage: 'Discord invite',
|
||||
},
|
||||
discordInviteDescription: {
|
||||
id: 'dashboard.projects.links.discord-invite.description',
|
||||
defaultMessage: 'An invitation link to your Discord server.',
|
||||
},
|
||||
existingLinkWillBeCleared: {
|
||||
id: 'dashboard.projects.links.placeholder.cleared',
|
||||
defaultMessage: 'Existing link will be cleared',
|
||||
},
|
||||
enterValidUrl: {
|
||||
id: 'dashboard.projects.links.placeholder.valid-url',
|
||||
defaultMessage: 'Enter a valid URL',
|
||||
},
|
||||
enterValidDiscordInviteUrl: {
|
||||
id: 'dashboard.projects.links.placeholder.valid-discord-url',
|
||||
defaultMessage: 'Enter a valid Discord invite URL',
|
||||
},
|
||||
clearLinkLabel: {
|
||||
id: 'dashboard.projects.links.button.clear-link',
|
||||
defaultMessage: 'Clear link',
|
||||
},
|
||||
changesAppliedTo: {
|
||||
id: 'dashboard.projects.links.changes-applied',
|
||||
defaultMessage:
|
||||
'Changes will be applied to <strong>{count}</strong> {count, plural, one {project} other {projects}}.',
|
||||
},
|
||||
andMore: {
|
||||
id: 'dashboard.projects.links.and-more',
|
||||
defaultMessage: 'and {count} more...',
|
||||
},
|
||||
showAllProjects: {
|
||||
id: 'dashboard.projects.links.show-all-projects',
|
||||
defaultMessage: 'Show all projects',
|
||||
},
|
||||
noProjectsYet: {
|
||||
id: 'dashboard.projects.empty',
|
||||
defaultMessage: "You don't have any projects yet. Click the green button above to begin.",
|
||||
},
|
||||
bulkEditHint: {
|
||||
id: 'dashboard.projects.bulk-edit-hint',
|
||||
defaultMessage: 'You can edit multiple projects at once by selecting them below.',
|
||||
},
|
||||
ascending: {
|
||||
id: 'dashboard.projects.sort.ascending',
|
||||
defaultMessage: 'Ascending',
|
||||
},
|
||||
descending: {
|
||||
id: 'dashboard.projects.sort.descending',
|
||||
defaultMessage: 'Descending',
|
||||
},
|
||||
sortOptionName: {
|
||||
id: 'dashboard.projects.sort.option.name',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
sortOptionStatus: {
|
||||
id: 'dashboard.projects.sort.option.status',
|
||||
defaultMessage: 'Status',
|
||||
},
|
||||
sortOptionType: {
|
||||
id: 'dashboard.projects.sort.option.type',
|
||||
defaultMessage: 'Type',
|
||||
},
|
||||
iconHeader: {
|
||||
id: 'dashboard.projects.table.icon',
|
||||
defaultMessage: 'Icon',
|
||||
},
|
||||
nameHeader: {
|
||||
id: 'dashboard.projects.table.name',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
idHeader: {
|
||||
id: 'dashboard.projects.table.id',
|
||||
defaultMessage: 'ID',
|
||||
},
|
||||
typeHeader: {
|
||||
id: 'dashboard.projects.table.type',
|
||||
defaultMessage: 'Type',
|
||||
},
|
||||
statusHeader: {
|
||||
id: 'dashboard.projects.table.status',
|
||||
defaultMessage: 'Status',
|
||||
},
|
||||
projectIconAlt: {
|
||||
id: 'dashboard.projects.project.icon-alt',
|
||||
defaultMessage: 'Icon for {title}',
|
||||
},
|
||||
projectModeratorMessageAriaLabel: {
|
||||
id: 'dashboard.projects.project.moderator-message-aria',
|
||||
defaultMessage: 'Project has a message from the moderators. View the project to see more.',
|
||||
},
|
||||
reviewEnvironmentMetadata: {
|
||||
id: 'dashboard.projects.project.review-environment-metadata',
|
||||
defaultMessage: 'Please review environment metadata',
|
||||
},
|
||||
serverBulkEditDisabled: {
|
||||
id: 'dashboard.projects.bulk-edit.server-disabled',
|
||||
defaultMessage: 'Server projects do not support bulk editing',
|
||||
},
|
||||
bulkEditSuccessText: {
|
||||
id: 'dashboard.projects.notification.bulk-edit-success',
|
||||
defaultMessage: "Bulk edited selected project's links.",
|
||||
},
|
||||
})
|
||||
|
||||
useHead({ title: () => `${formatMessage(messages.headTitle)} - Modrinth` })
|
||||
|
||||
const user = await useUser()
|
||||
const projects = ref([])
|
||||
const projectsWithMigrationWarning = ref([])
|
||||
const selectedProjects = ref([])
|
||||
const sortBy = ref('Name')
|
||||
const sortOptions = [
|
||||
{ value: 'Name', label: 'Name' },
|
||||
{ value: 'Status', label: 'Status' },
|
||||
{ value: 'Type', label: 'Type' },
|
||||
]
|
||||
const sortOptions = computed(() => [
|
||||
{ value: 'Name', label: formatMessage(messages.sortOptionName) },
|
||||
{ value: 'Status', label: formatMessage(messages.sortOptionStatus) },
|
||||
{ value: 'Type', label: formatMessage(messages.sortOptionType) },
|
||||
])
|
||||
const descending = ref(false)
|
||||
const editLinks = reactive({
|
||||
showAffected: false,
|
||||
@@ -370,6 +503,16 @@ const editLinks = reactive({
|
||||
const editLinksModal = ref(null)
|
||||
const modal_creation = ref(null)
|
||||
|
||||
function getLinkInputPlaceholder(clearLink, isDiscord = false) {
|
||||
if (clearLink) {
|
||||
return formatMessage(messages.existingLinkWillBeCleared)
|
||||
}
|
||||
|
||||
return isDiscord
|
||||
? formatMessage(messages.enterValidDiscordInviteUrl)
|
||||
: formatMessage(messages.enterValidUrl)
|
||||
}
|
||||
|
||||
function isProjectBulkEditDisabled(project) {
|
||||
return (
|
||||
(project.permissions & EDIT_DETAILS) === EDIT_DETAILS ||
|
||||
@@ -408,7 +551,7 @@ function toggleProjectSelection(project) {
|
||||
|
||||
function getBulkEditDisabledTooltip(project) {
|
||||
if (project.project_type === 'minecraft_java_server') {
|
||||
return 'Server projects do not support bulk editing'
|
||||
return formatMessage(messages.serverBulkEditDisabled)
|
||||
}
|
||||
|
||||
return ''
|
||||
@@ -467,8 +610,8 @@ async function bulkEditLinks() {
|
||||
|
||||
editLinksModal.value?.hide()
|
||||
addNotification({
|
||||
title: 'Success',
|
||||
text: "Bulk edited selected project's links.",
|
||||
title: formatMessage(commonMessages.successLabel),
|
||||
text: formatMessage(messages.bulkEditSuccessText),
|
||||
type: 'success',
|
||||
})
|
||||
selectedProjects.value = []
|
||||
@@ -483,7 +626,7 @@ async function bulkEditLinks() {
|
||||
editLinks.discord.clear = false
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: e,
|
||||
type: 'error',
|
||||
})
|
||||
|
||||
@@ -2,16 +2,33 @@
|
||||
<ReportView
|
||||
:auth="auth"
|
||||
:report-id="route.params.id"
|
||||
:breadcrumbs-stack="[{ href: '/dashboard/reports', label: 'Active reports' }]"
|
||||
:breadcrumbs-stack="[
|
||||
{ href: '/dashboard/reports', label: formatMessage(messages.activeReportsTitle) },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import ReportView from '~/components/ui/report/ReportView.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
activeReportsTitle: {
|
||||
id: 'dashboard.reports.active-title',
|
||||
defaultMessage: 'Active reports',
|
||||
},
|
||||
reportTitle: {
|
||||
id: 'dashboard.report.title',
|
||||
defaultMessage: 'Report {id}',
|
||||
},
|
||||
})
|
||||
|
||||
const route = useNativeRoute()
|
||||
const auth = await useAuth()
|
||||
|
||||
useHead({
|
||||
title: `Report ${route.params.id} - Modrinth`,
|
||||
title: () => `${formatMessage(messages.reportTitle, { id: route.params.id })} - Modrinth`,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">Reports</h2>
|
||||
<h2 class="text-2xl">{{ formatMessage(messages.reportsTitle) }}</h2>
|
||||
<ReportsList :auth="auth" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import ReportsList from '~/components/ui/report/ReportsList.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
reportsTitle: {
|
||||
id: 'dashboard.reports.title',
|
||||
defaultMessage: 'Reports',
|
||||
},
|
||||
activeReportsTitle: {
|
||||
id: 'dashboard.reports.active-title',
|
||||
defaultMessage: 'Active reports',
|
||||
},
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
useHead({
|
||||
title: 'Active reports - Modrinth',
|
||||
title: () => `${formatMessage(messages.activeReportsTitle)} - Modrinth`,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -298,8 +298,14 @@ async function openWithdrawModal() {
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
balanceLabel: { id: 'dashboard.revenue.balance', defaultMessage: 'Balance' },
|
||||
availableNow: { id: 'dashboard.revenue.available-now', defaultMessage: 'Available now' },
|
||||
balanceLabel: {
|
||||
id: 'dashboard.revenue.balance',
|
||||
defaultMessage: 'Balance',
|
||||
},
|
||||
availableNow: {
|
||||
id: 'dashboard.revenue.available-now',
|
||||
defaultMessage: 'Available now',
|
||||
},
|
||||
estimatedWithDate: {
|
||||
id: 'dashboard.revenue.estimated-with-date',
|
||||
defaultMessage: 'Estimated {date}',
|
||||
@@ -312,14 +318,23 @@ const messages = defineMessages({
|
||||
id: 'dashboard.revenue.estimated-tooltip.msg2',
|
||||
defaultMessage: 'Click to read about how Modrinth handles your revenue.',
|
||||
},
|
||||
processing: { id: 'dashboard.revenue.processing', defaultMessage: 'Processing' },
|
||||
processing: {
|
||||
id: 'dashboard.revenue.processing',
|
||||
defaultMessage: 'Processing',
|
||||
},
|
||||
processingTooltip: {
|
||||
id: 'dashboard.revenue.processing.tooltip',
|
||||
defaultMessage:
|
||||
'Revenue stays in processing until the end of the month, then becomes available 60 days later.',
|
||||
},
|
||||
withdrawHeader: { id: 'dashboard.revenue.withdraw.header', defaultMessage: 'Withdraw' },
|
||||
withdrawCardTitle: { id: 'dashboard.revenue.withdraw.card.title', defaultMessage: 'Withdraw' },
|
||||
withdrawHeader: {
|
||||
id: 'dashboard.revenue.withdraw.header',
|
||||
defaultMessage: 'Withdraw',
|
||||
},
|
||||
withdrawCardTitle: {
|
||||
id: 'dashboard.revenue.withdraw.card.title',
|
||||
defaultMessage: 'Withdraw',
|
||||
},
|
||||
withdrawCardDescription: {
|
||||
id: 'dashboard.revenue.withdraw.card.description',
|
||||
defaultMessage: 'Withdraw from your available balance to any payout method.',
|
||||
@@ -338,7 +353,10 @@ const messages = defineMessages({
|
||||
id: 'dashboard.revenue.transactions.header',
|
||||
defaultMessage: 'Transactions',
|
||||
},
|
||||
seeAll: { id: 'dashboard.revenue.transactions.see-all', defaultMessage: 'See all' },
|
||||
seeAll: {
|
||||
id: 'dashboard.revenue.transactions.see-all',
|
||||
defaultMessage: 'See all',
|
||||
},
|
||||
noTransactions: {
|
||||
id: 'dashboard.revenue.transactions.none',
|
||||
defaultMessage: 'No transactions',
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
<Combobox
|
||||
v-model="selectedYear"
|
||||
:options="yearOptions"
|
||||
:display-value="selectedYear === 'all' ? 'All years' : String(selectedYear)"
|
||||
:display-value="
|
||||
selectedYear === 'all' ? formatMessage(messages.allYears) : String(selectedYear)
|
||||
"
|
||||
listbox
|
||||
/>
|
||||
<ButtonStyled circular>
|
||||
@@ -116,7 +118,7 @@ const client = injectModrinthClient()
|
||||
const generatedState = useGeneratedState()
|
||||
|
||||
useHead({
|
||||
title: 'Transaction history - Modrinth',
|
||||
title: () => `${formatMessage(messages.headTitle)} - Modrinth`,
|
||||
})
|
||||
|
||||
const { data: transactions, refetch } = useQuery({
|
||||
@@ -143,7 +145,7 @@ const yearOptions = computed(() => {
|
||||
|
||||
return yearValues.map((year) => ({
|
||||
value: year,
|
||||
label: year === 'all' ? 'All years' : String(year),
|
||||
label: year === 'all' ? formatMessage(messages.allYears) : String(year),
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -161,9 +163,9 @@ function getPeriodLabel(date) {
|
||||
const now = dayjs()
|
||||
|
||||
if (txnDate.isSame(now, 'month')) {
|
||||
return 'This month'
|
||||
return formatMessage(messages.thisMonth)
|
||||
} else if (txnDate.isSame(now.subtract(1, 'month'), 'month')) {
|
||||
return 'Last month'
|
||||
return formatMessage(messages.lastMonth)
|
||||
} else {
|
||||
return capitalizeString(formatMonth(txnDate.toDate()))
|
||||
}
|
||||
@@ -322,6 +324,10 @@ const messages = defineMessages({
|
||||
id: 'dashboard.revenue.transactions.header',
|
||||
defaultMessage: 'Transactions',
|
||||
},
|
||||
headTitle: {
|
||||
id: 'dashboard.revenue.transactions.head-title',
|
||||
defaultMessage: 'Transaction history',
|
||||
},
|
||||
received: {
|
||||
id: 'dashboard.revenue.stats.received',
|
||||
defaultMessage: 'Received',
|
||||
@@ -346,5 +352,17 @@ const messages = defineMessages({
|
||||
id: 'dashboard.revenue.transactions.btn.download-csv',
|
||||
defaultMessage: 'Download as CSV',
|
||||
},
|
||||
allYears: {
|
||||
id: 'dashboard.revenue.transactions.year.all',
|
||||
defaultMessage: 'All years',
|
||||
},
|
||||
thisMonth: {
|
||||
id: 'dashboard.revenue.transactions.period.this-month',
|
||||
defaultMessage: 'This month',
|
||||
},
|
||||
lastMonth: {
|
||||
id: 'dashboard.revenue.transactions.period.last-month',
|
||||
defaultMessage: 'Last month',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ImageIcon,
|
||||
ListIcon,
|
||||
MoreVerticalIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { CardAction, CreationFlowContextValue } from '@modrinth/ui'
|
||||
import {
|
||||
@@ -172,6 +173,43 @@ const serverIcon = computed(() => {
|
||||
})
|
||||
|
||||
const serverHideInstalled = ref(false)
|
||||
const installingProjectIds = ref<Set<string>>(new Set())
|
||||
const optimisticallyInstalledProjectIds = ref<Set<string>>(new Set())
|
||||
const hiddenInstalledProjectIds = ref<Set<string>>(new Set())
|
||||
const hiddenInstalledProjectIdsInitialized = ref(false)
|
||||
|
||||
function setProjectInstalling(projectId: string, installing: boolean) {
|
||||
const next = new Set(installingProjectIds.value)
|
||||
if (installing) {
|
||||
next.add(projectId)
|
||||
} else {
|
||||
next.delete(projectId)
|
||||
}
|
||||
installingProjectIds.value = next
|
||||
}
|
||||
|
||||
function markProjectInstalled(projectId: string) {
|
||||
optimisticallyInstalledProjectIds.value = new Set([
|
||||
...optimisticallyInstalledProjectIds.value,
|
||||
projectId,
|
||||
])
|
||||
}
|
||||
|
||||
function getServerInstalledProjectIds(data = serverContentData.value) {
|
||||
return new Set(
|
||||
(data?.addons ?? [])
|
||||
.map((addon) => addon.project_id)
|
||||
.filter((projectId): projectId is string => !!projectId),
|
||||
)
|
||||
}
|
||||
|
||||
function syncHiddenInstalledProjectIds() {
|
||||
hiddenInstalledProjectIds.value = new Set([
|
||||
...getServerInstalledProjectIds(),
|
||||
...optimisticallyInstalledProjectIds.value,
|
||||
])
|
||||
hiddenInstalledProjectIdsInitialized.value = true
|
||||
}
|
||||
|
||||
const contentQueryKey = computed(() => ['content', 'list', currentServerId.value ?? ''] as const)
|
||||
const { data: serverContentData, error: serverContentError } = useQuery({
|
||||
@@ -187,6 +225,17 @@ watch(serverContentError, (error) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
serverContentData,
|
||||
(data) => {
|
||||
if (!data) return
|
||||
if (!hiddenInstalledProjectIdsInitialized.value) {
|
||||
syncHiddenInstalledProjectIds()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const installContentMutation = useMutation({
|
||||
mutationFn: ({
|
||||
serverId,
|
||||
@@ -212,6 +261,12 @@ if (route.query.shi && projectType.value?.id !== 'modpack') {
|
||||
serverHideInstalled.value = route.query.shi === 'true'
|
||||
}
|
||||
|
||||
watch(serverHideInstalled, (hideInstalled) => {
|
||||
if (hideInstalled) {
|
||||
syncHiddenInstalledProjectIds()
|
||||
}
|
||||
})
|
||||
|
||||
const serverFilters = computed(() => {
|
||||
debug(
|
||||
'serverFilters recomputing, serverData:',
|
||||
@@ -242,19 +297,14 @@ const serverFilters = computed(() => {
|
||||
filters.push({ type: 'environment', option: 'server' })
|
||||
}
|
||||
|
||||
if (serverHideInstalled.value && serverContentData.value) {
|
||||
const installedIds = (serverContentData.value.addons ?? [])
|
||||
.filter((x) => x.project_id)
|
||||
.map((x) => x.project_id)
|
||||
.filter((id): id is string => id !== null)
|
||||
|
||||
installedIds
|
||||
.map((x: string) => ({
|
||||
if (serverHideInstalled.value && hiddenInstalledProjectIds.value.size > 0) {
|
||||
for (const x of hiddenInstalledProjectIds.value) {
|
||||
filters.push({
|
||||
type: 'project_id',
|
||||
option: `project_id:${x}`,
|
||||
negative: true,
|
||||
}))
|
||||
.forEach((x) => filters.push(x))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +319,6 @@ const serverFilters = computed(() => {
|
||||
})
|
||||
|
||||
interface InstallableSearchResult extends Labrinth.Search.v2.ResultSearchProject {
|
||||
installing?: boolean
|
||||
installed?: boolean
|
||||
}
|
||||
|
||||
@@ -278,7 +327,7 @@ async function serverInstall(project: InstallableSearchResult) {
|
||||
handleError(new Error('No server to install to.'))
|
||||
return
|
||||
}
|
||||
project.installing = true
|
||||
setProjectInstalling(project.project_id, true)
|
||||
try {
|
||||
if (projectType.value?.id === 'modpack') {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, {
|
||||
@@ -287,7 +336,7 @@ async function serverInstall(project: InstallableSearchResult) {
|
||||
const versionId = versions[0]?.id ?? project.latest_version
|
||||
if (!versionId) {
|
||||
handleError(new Error('No version found for this modpack'))
|
||||
project.installing = false
|
||||
setProjectInstalling(project.project_id, false)
|
||||
return
|
||||
}
|
||||
const modalInstance = onboardingModalRef.value
|
||||
@@ -326,7 +375,7 @@ async function serverInstall(project: InstallableSearchResult) {
|
||||
: `No compatible version found for ${serverData.value!.mc_version} / ${serverData.value!.loader}`,
|
||||
),
|
||||
)
|
||||
project.installing = false
|
||||
setProjectInstalling(project.project_id, false)
|
||||
return
|
||||
}
|
||||
await installContentMutation.mutateAsync({
|
||||
@@ -334,13 +383,13 @@ async function serverInstall(project: InstallableSearchResult) {
|
||||
projectId: version.project_id,
|
||||
versionId: version.id,
|
||||
})
|
||||
project.installed = true
|
||||
markProjectInstalled(project.project_id)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
handleError(new Error(`Error installing content ${e}`))
|
||||
}
|
||||
project.installing = false
|
||||
setProjectInstalling(project.project_id, false)
|
||||
}
|
||||
|
||||
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
|
||||
@@ -447,16 +496,19 @@ function getCardActions(
|
||||
if (serverData.value) {
|
||||
const isInstalled =
|
||||
projectResult.installed ||
|
||||
optimisticallyInstalledProjectIds.value.has(result.project_id) ||
|
||||
(serverContentData.value &&
|
||||
(serverContentData.value.addons ?? []).find((x) => x.project_id === result.project_id)) ||
|
||||
serverData.value.upstream?.project_id === result.project_id
|
||||
const isInstalling = installingProjectIds.value.has(result.project_id)
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'install',
|
||||
label: projectResult.installing ? 'Installing...' : isInstalled ? 'Installed' : 'Install',
|
||||
icon: isInstalled ? CheckIcon : DownloadIcon,
|
||||
disabled: !!isInstalled || !!projectResult.installing,
|
||||
label: isInstalling ? 'Installing...' : isInstalled ? 'Installed' : 'Install',
|
||||
icon: isInstalling ? SpinnerIcon : isInstalled ? CheckIcon : DownloadIcon,
|
||||
iconClass: isInstalling ? 'animate-spin' : undefined,
|
||||
disabled: !!isInstalled || isInstalling,
|
||||
color: 'brand',
|
||||
type: 'outlined',
|
||||
onClick: () => serverInstall(projectResult),
|
||||
@@ -472,7 +524,7 @@ const onboardingInstallingProject = ref<InstallableSearchResult | null>(null)
|
||||
|
||||
function onOnboardingHide() {
|
||||
if (onboardingInstallingProject.value) {
|
||||
onboardingInstallingProject.value.installing = false
|
||||
setProjectInstalling(onboardingInstallingProject.value.project_id, false)
|
||||
onboardingInstallingProject.value = null
|
||||
}
|
||||
}
|
||||
@@ -587,6 +639,19 @@ const searchState = useBrowseSearch({
|
||||
displayMode: resultsDisplayMode,
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
() => searchState.query.value,
|
||||
() => searchState.currentFilters.value,
|
||||
() => searchState.serverCurrentFilters.value,
|
||||
() => projectTypeId.value,
|
||||
],
|
||||
() => {
|
||||
syncHiddenInstalledProjectIds()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
debug('calling initial refreshSearch')
|
||||
searchState.refreshSearch()
|
||||
|
||||
@@ -622,7 +687,7 @@ provideBrowseManager({
|
||||
providedFilters: serverFilters,
|
||||
hideInstalled: serverHideInstalled,
|
||||
showHideInstalled: computed(() => !!serverData.value && projectType.value?.id !== 'modpack'),
|
||||
hideInstalledLabel: computed(() => 'Hide installed content'),
|
||||
hideInstalledLabel: computed(() => 'Hide already installed content'),
|
||||
displayMode: resultsDisplayMode,
|
||||
cycleDisplayMode: cycleSearchDisplayMode,
|
||||
maxResultsOptions: currentMaxResultsOptions,
|
||||
|
||||
@@ -112,13 +112,13 @@ import ConfettiExplosion from 'vue-confetti-explosion'
|
||||
|
||||
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
|
||||
import { enrichProjectBatch, type ModerationProject } from '~/helpers/moderation.ts'
|
||||
import { useModerationStore } from '~/store/moderation.ts'
|
||||
import { useModerationQueue } from '~/services/moderation-queue.ts'
|
||||
|
||||
useHead({ title: 'Projects queue - Modrinth' })
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const moderationStore = useModerationStore()
|
||||
const moderationQueue = useModerationQueue()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -331,18 +331,18 @@ function goToPage(page: number) {
|
||||
async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
|
||||
let skippedCount = 0
|
||||
|
||||
while (moderationStore.hasItems) {
|
||||
const currentId = moderationStore.getCurrentProjectId()
|
||||
while (moderationQueue.hasItems) {
|
||||
const currentId = moderationQueue.getCurrentProjectId()
|
||||
if (!currentId) return null
|
||||
|
||||
const project = filteredProjects.value.find((p) => p.project.id === currentId)
|
||||
if (!project) {
|
||||
moderationStore.completeCurrentProject(currentId, 'skipped')
|
||||
await moderationQueue.completeCurrentProject(currentId, 'skipped')
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const lockStatus = await moderationStore.checkLock(currentId)
|
||||
const lockStatus = await moderationQueue.checkLock(currentId)
|
||||
|
||||
if (!lockStatus.locked || lockStatus.expired) {
|
||||
if (skippedCount > 0) {
|
||||
@@ -356,7 +356,7 @@ async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
|
||||
}
|
||||
|
||||
// Project is locked, skip it
|
||||
moderationStore.completeCurrentProject(currentId, 'skipped')
|
||||
await moderationQueue.completeCurrentProject(currentId, 'skipped')
|
||||
skippedCount++
|
||||
} catch {
|
||||
return project
|
||||
@@ -371,7 +371,7 @@ async function moderateAllInFilter() {
|
||||
const startIndex = (currentPage.value - 1) * itemsPerPage
|
||||
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
|
||||
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
|
||||
moderationStore.setQueue(projectIds)
|
||||
await moderationQueue.setQueue(projectIds)
|
||||
|
||||
// Find first unlocked project
|
||||
const targetProject = await findFirstUnlockedProject()
|
||||
@@ -402,12 +402,12 @@ async function startFromProject(projectId: string) {
|
||||
const projectIndex = filteredProjects.value.findIndex((p) => p.project.id === projectId)
|
||||
if (projectIndex === -1) {
|
||||
// Project not found in filtered list, just moderate it alone
|
||||
moderationStore.setSingleProject(projectId)
|
||||
await moderationQueue.setSingleProject(projectId)
|
||||
} else {
|
||||
// Start queue from this project onwards
|
||||
const projectsFromHere = filteredProjects.value.slice(projectIndex)
|
||||
const projectIds = projectsFromHere.map((queueItem) => queueItem.project.id)
|
||||
moderationStore.setQueue(projectIds)
|
||||
await moderationQueue.setQueue(projectIds)
|
||||
}
|
||||
|
||||
// Find first unlocked project
|
||||
|
||||
@@ -83,9 +83,10 @@ if (import.meta.client) {
|
||||
|
||||
const loadingIssues = reactive<Set<string>>(new Set())
|
||||
const decompiledSources = reactive<Map<string, string>>(new Map())
|
||||
const loadedIssues = reactive<Set<string>>(new Set())
|
||||
|
||||
async function loadIssueSource(issueId: string): Promise<void> {
|
||||
if (loadingIssues.has(issueId)) return
|
||||
if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return
|
||||
|
||||
loadingIssues.add(issueId)
|
||||
|
||||
@@ -98,6 +99,7 @@ async function loadIssueSource(issueId: string): Promise<void> {
|
||||
setCachedSource(detail.id, detail.decompiled_source)
|
||||
}
|
||||
}
|
||||
loadedIssues.add(issueId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load issue source:', error)
|
||||
} finally {
|
||||
@@ -105,37 +107,40 @@ async function loadIssueSource(issueId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function tryLoadCachedSourcesForFile(reportId: string): void {
|
||||
if (!reviewItem.value) return
|
||||
function findIssuesByIds(issueIds: Set<string>): Labrinth.TechReview.Internal.FileIssue[] {
|
||||
const issues: Labrinth.TechReview.Internal.FileIssue[] = []
|
||||
|
||||
const report = reviewItem.value.reports.find((r) => r.id === reportId)
|
||||
if (report) {
|
||||
if (!reviewItem.value) return []
|
||||
|
||||
for (const report of reviewItem.value.reports) {
|
||||
for (const issue of report.issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (!decompiledSources.has(detail.id)) {
|
||||
const cached = getCachedSource(detail.id)
|
||||
if (cached) {
|
||||
decompiledSources.set(detail.id, cached)
|
||||
}
|
||||
}
|
||||
if (issueIds.has(issue.id)) {
|
||||
issues.push(issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function handleLoadFileSources(reportId: string): void {
|
||||
tryLoadCachedSourcesForFile(reportId)
|
||||
function handleLoadIssueSources(issueIds: string[]): void {
|
||||
const uniqueIssueIds = new Set(issueIds)
|
||||
const issues = findIssuesByIds(uniqueIssueIds)
|
||||
|
||||
if (!reviewItem.value) return
|
||||
|
||||
const report = reviewItem.value.reports.find((r) => r.id === reportId)
|
||||
if (report) {
|
||||
for (const issue of report.issues) {
|
||||
const hasUncached = issue.details.some((d) => !decompiledSources.has(d.id))
|
||||
if (hasUncached) {
|
||||
loadIssueSource(issue.id)
|
||||
for (const issue of issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (!decompiledSources.has(detail.id)) {
|
||||
const cached = getCachedSource(detail.id)
|
||||
if (cached) {
|
||||
decompiledSources.set(detail.id, cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
|
||||
if (hasUncached) {
|
||||
loadIssueSource(issue.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,8 +246,14 @@ const reviewItem = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
function handleMarkComplete(_projectId: string) {
|
||||
queryClient.invalidateQueries({ queryKey: ['tech-reviews'] })
|
||||
async function handleMarkComplete(projectId: string) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['tech-reviews'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['tech-review-project-report', projectId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['project', projectId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId] }),
|
||||
])
|
||||
}
|
||||
|
||||
const maliciousSummaryModalRef = ref<InstanceType<typeof MaliciousSummaryModal>>()
|
||||
@@ -292,7 +303,7 @@ function refetch() {
|
||||
:loading-issues="loadingIssues"
|
||||
:decompiled-sources="decompiledSources"
|
||||
@refetch="refetch"
|
||||
@load-file-sources="handleLoadFileSources"
|
||||
@load-issue-sources="handleLoadIssueSources"
|
||||
@mark-complete="handleMarkComplete"
|
||||
@show-malicious-summary="handleShowMaliciousSummary"
|
||||
/>
|
||||
|
||||
@@ -104,9 +104,10 @@ clearExpiredCache()
|
||||
|
||||
const loadingIssues = reactive<Set<string>>(new Set())
|
||||
const decompiledSources = reactive<Map<string, string>>(new Map())
|
||||
const loadedIssues = reactive<Set<string>>(new Set())
|
||||
|
||||
async function loadIssueSource(issueId: string): Promise<void> {
|
||||
if (loadingIssues.has(issueId)) return
|
||||
if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return
|
||||
|
||||
loadingIssues.add(issueId)
|
||||
|
||||
@@ -119,6 +120,7 @@ async function loadIssueSource(issueId: string): Promise<void> {
|
||||
setCachedSource(detail.id, detail.decompiled_source)
|
||||
}
|
||||
}
|
||||
loadedIssues.add(issueId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load issue source:', error)
|
||||
} finally {
|
||||
@@ -126,38 +128,39 @@ async function loadIssueSource(issueId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function tryLoadCachedSourcesForFile(reportId: string): void {
|
||||
function findIssuesByIds(issueIds: Set<string>): Labrinth.TechReview.Internal.FileIssue[] {
|
||||
const issues: Labrinth.TechReview.Internal.FileIssue[] = []
|
||||
|
||||
for (const review of reviewItems.value) {
|
||||
const report = review.reports.find((r) => r.id === reportId)
|
||||
if (report) {
|
||||
for (const report of review.reports) {
|
||||
for (const issue of report.issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (!decompiledSources.has(detail.id)) {
|
||||
const cached = getCachedSource(detail.id)
|
||||
if (cached) {
|
||||
decompiledSources.set(detail.id, cached)
|
||||
}
|
||||
}
|
||||
if (issueIds.has(issue.id)) {
|
||||
issues.push(issue)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function handleLoadFileSources(reportId: string): void {
|
||||
tryLoadCachedSourcesForFile(reportId)
|
||||
function handleLoadIssueSources(issueIds: string[]): void {
|
||||
const uniqueIssueIds = new Set(issueIds)
|
||||
const issues = findIssuesByIds(uniqueIssueIds)
|
||||
|
||||
for (const review of reviewItems.value) {
|
||||
const report = review.reports.find((r) => r.id === reportId)
|
||||
if (report) {
|
||||
for (const issue of report.issues) {
|
||||
const hasUncached = issue.details.some((d) => !decompiledSources.has(d.id))
|
||||
if (hasUncached) {
|
||||
loadIssueSource(issue.id)
|
||||
for (const issue of issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (!decompiledSources.has(detail.id)) {
|
||||
const cached = getCachedSource(detail.id)
|
||||
if (cached) {
|
||||
decompiledSources.set(detail.id, cached)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
|
||||
if (hasUncached) {
|
||||
loadIssueSource(issue.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,8 +228,32 @@ const responseFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Read', label: 'Read' },
|
||||
]
|
||||
|
||||
const currentProjectTypeFilter = ref('All project types')
|
||||
const projectTypeFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'All project types', label: 'All project types' },
|
||||
{ value: 'Modpacks', label: 'Modpacks' },
|
||||
{ value: 'Mods', label: 'Mods' },
|
||||
{ value: 'Resource Packs', label: 'Resource Packs' },
|
||||
{ value: 'Data Packs', label: 'Data Packs' },
|
||||
{ value: 'Plugins', label: 'Plugins' },
|
||||
{ value: 'Shaders', label: 'Shaders' },
|
||||
{ value: 'Servers', label: 'Servers' },
|
||||
]
|
||||
|
||||
const inOtherQueueFilter = ref(true)
|
||||
|
||||
const techReviewQueryKey = computed(
|
||||
() =>
|
||||
[
|
||||
'tech-reviews',
|
||||
currentSortType.value,
|
||||
currentResponseFilter.value,
|
||||
inOtherQueueFilter.value,
|
||||
currentFilterType.value,
|
||||
currentProjectTypeFilter.value,
|
||||
] as const,
|
||||
)
|
||||
|
||||
const fuse = computed(() => {
|
||||
if (!reviewItems.value || reviewItems.value.length === 0) return null
|
||||
return new Fuse(reviewItems.value, {
|
||||
@@ -294,6 +321,27 @@ function toApiSort(label: string): Labrinth.TechReview.Internal.SearchProjectsSo
|
||||
}
|
||||
}
|
||||
|
||||
function toApiProjectType(label: string): string | undefined {
|
||||
switch (label) {
|
||||
case 'Modpacks':
|
||||
return 'modpack'
|
||||
case 'Mods':
|
||||
return 'mod'
|
||||
case 'Resource Packs':
|
||||
return 'resourcepack'
|
||||
case 'Data Packs':
|
||||
return 'datapack'
|
||||
case 'Plugins':
|
||||
return 'plugin'
|
||||
case 'Shaders':
|
||||
return 'shader'
|
||||
case 'Servers':
|
||||
return 'minecraft_java_server'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
data: infiniteData,
|
||||
isLoading,
|
||||
@@ -303,13 +351,7 @@ const {
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
enabled: true,
|
||||
queryKey: [
|
||||
'tech-reviews',
|
||||
currentSortType,
|
||||
currentResponseFilter,
|
||||
inOtherQueueFilter,
|
||||
currentFilterType,
|
||||
],
|
||||
queryKey: techReviewQueryKey,
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
const filter: Labrinth.TechReview.Internal.SearchProjectsFilter = {
|
||||
project_type: [],
|
||||
@@ -332,6 +374,11 @@ const {
|
||||
filter.issue_type = [currentFilterType.value]
|
||||
}
|
||||
|
||||
const projectType = toApiProjectType(currentProjectTypeFilter.value)
|
||||
if (projectType) {
|
||||
filter.project_type = [projectType]
|
||||
}
|
||||
|
||||
return await client.labrinth.tech_review_internal.searchProjects({
|
||||
limit: API_PAGE_SIZE,
|
||||
page: pageParam,
|
||||
@@ -430,7 +477,7 @@ function handleMarkComplete(projectId: string) {
|
||||
const threadId = projectData?.thread?.id
|
||||
|
||||
queryClient.setQueryData(
|
||||
['tech-reviews', currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilterType],
|
||||
techReviewQueryKey.value,
|
||||
(
|
||||
oldData:
|
||||
| {
|
||||
@@ -445,7 +492,8 @@ function handleMarkComplete(projectId: string) {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
project_reports: page.project_reports.filter((pr) => pr.project_id !== projectId),
|
||||
// Keep the raw page length stable; getNextPageParam uses it to know if more API pages exist.
|
||||
project_reports: page.project_reports,
|
||||
projects: Object.fromEntries(
|
||||
Object.entries(page.projects).filter(([id]) => id !== projectId),
|
||||
),
|
||||
@@ -492,8 +540,28 @@ function handleShowMaliciousSummary(unsafeFiles: UnsafeFile[]) {
|
||||
maliciousSummaryModalRef.value?.show()
|
||||
}
|
||||
|
||||
watch([currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilterType], () => {
|
||||
goToPage(1)
|
||||
watch(
|
||||
[
|
||||
currentSortType,
|
||||
currentResponseFilter,
|
||||
inOtherQueueFilter,
|
||||
currentFilterType,
|
||||
currentProjectTypeFilter,
|
||||
],
|
||||
() => {
|
||||
goToPage(1)
|
||||
},
|
||||
)
|
||||
|
||||
watch(totalPages, (pages) => {
|
||||
if (pages === 0) {
|
||||
goToPage(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (currentPage.value > pages) {
|
||||
goToPage(pages)
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: Reimpl when backend is available
|
||||
@@ -597,6 +665,23 @@ watch([currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilter
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-secondary">Project type</span>
|
||||
<Combobox
|
||||
v-model="currentProjectTypeFilter"
|
||||
class="!w-full"
|
||||
:options="projectTypeFilterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
searchable
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
|
||||
<span class="truncate text-contrast">{{ currentProjectTypeFilter }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FloatingPanel>
|
||||
@@ -635,7 +720,7 @@ watch([currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilter
|
||||
:loading-issues="loadingIssues"
|
||||
:decompiled-sources="decompiledSources"
|
||||
@refetch="refetch"
|
||||
@load-file-sources="handleLoadFileSources"
|
||||
@load-issue-sources="handleLoadIssueSources"
|
||||
@mark-complete="handleMarkComplete"
|
||||
@show-malicious-summary="handleShowMaliciousSummary"
|
||||
/>
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeftIcon } from '@modrinth/assets'
|
||||
import { getChangelog } from '@modrinth/blog'
|
||||
import { ChangelogEntry, Timeline } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
|
||||
import {
|
||||
findChangelogEntry,
|
||||
resolveChangelogEntries,
|
||||
type AppRelease,
|
||||
} from '~/helpers/changelog'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const { data: appReleases, suspense: appReleasesSuspense } = useQuery({
|
||||
queryKey: ['changelog', 'app-releases'],
|
||||
queryFn: () => $fetch<AppRelease[]>('/api/changelog/app-releases'),
|
||||
})
|
||||
|
||||
await appReleasesSuspense().catch(() => {})
|
||||
|
||||
const changelogEntries = computed(() => resolveChangelogEntries(appReleases.value ?? []))
|
||||
const changelogEntry = computed(() =>
|
||||
route.params.date
|
||||
? getChangelog().find((x) => {
|
||||
if (x.product === route.params.product) {
|
||||
if (x.version && x.version === route.params.date) {
|
||||
return x
|
||||
} else if (x.date.unix() === Number(route.params.date as string)) {
|
||||
return x
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
route.params.date && route.params.product
|
||||
? findChangelogEntry(changelogEntries.value, route.params.product, route.params.date)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const isFirst = computed(() => changelogEntry.value?.date === getChangelog()[0].date)
|
||||
const isFirst = computed(() => changelogEntry.value === changelogEntries.value[0])
|
||||
|
||||
if (!changelogEntry.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Version not found' })
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { getChangelog, type Product } from '@modrinth/blog'
|
||||
import type { Product } from '@modrinth/blog'
|
||||
import { ChangelogEntry, NavTabs } from '@modrinth/ui'
|
||||
import Timeline from '@modrinth/ui/src/components/base/Timeline.vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
|
||||
import { resolveChangelogEntries, type AppRelease } from '~/helpers/changelog'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const filter = ref<Product | undefined>(undefined)
|
||||
const allChangelogEntries = ref(getChangelog())
|
||||
const { data: appReleases, suspense: appReleasesSuspense } = useQuery({
|
||||
queryKey: ['changelog', 'app-releases'],
|
||||
queryFn: () => $fetch<AppRelease[]>('/api/changelog/app-releases'),
|
||||
})
|
||||
|
||||
onServerPrefetch(async () => {
|
||||
await appReleasesSuspense().catch(() => {})
|
||||
})
|
||||
|
||||
function updateFilter() {
|
||||
if (route.query.filter) {
|
||||
@@ -24,7 +34,9 @@ watch(
|
||||
)
|
||||
|
||||
const changelogEntries = computed(() =>
|
||||
allChangelogEntries.value.filter((x) => !filter.value || x.product === filter.value),
|
||||
resolveChangelogEntries(appReleases.value ?? []).filter(
|
||||
(entry) => !filter.value || entry.product === filter.value,
|
||||
),
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -54,7 +66,7 @@ const changelogEntries = computed(() =>
|
||||
<Timeline fade-out-end>
|
||||
<ChangelogEntry
|
||||
v-for="(entry, index) in changelogEntries"
|
||||
:key="entry.date"
|
||||
:key="`${entry.product}-${entry.version ?? entry.date.unix()}`"
|
||||
:entry="entry"
|
||||
:first="index === 0"
|
||||
:show-type="filter === undefined"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
const CACHE_MAX_AGE = 60 * 30
|
||||
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/modrinth/code/releases'
|
||||
const PAGE_SIZE = 100
|
||||
|
||||
interface GitHubRelease {
|
||||
tag_name: string
|
||||
html_url: string
|
||||
published_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AppRelease {
|
||||
version: string
|
||||
publishedAt: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export default defineCachedEventHandler(
|
||||
async (): Promise<AppRelease[]> => {
|
||||
const releases: GitHubRelease[] = []
|
||||
let page = 1
|
||||
|
||||
while (true) {
|
||||
const response = await fetch(`${GITHUB_RELEASES_URL}?per_page=${PAGE_SIZE}&page=${page}`, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'modrinth-changelog',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
message: `GitHub releases request failed with ${response.status}`,
|
||||
})
|
||||
}
|
||||
|
||||
const pageReleases = (await response.json()) as GitHubRelease[]
|
||||
if (!Array.isArray(pageReleases)) {
|
||||
throw createError({ statusCode: 502, message: 'Invalid GitHub releases response' })
|
||||
}
|
||||
|
||||
releases.push(...pageReleases)
|
||||
|
||||
if (pageReleases.length < PAGE_SIZE) {
|
||||
break
|
||||
}
|
||||
|
||||
page++
|
||||
}
|
||||
|
||||
return releases
|
||||
.filter((release) => release.tag_name.startsWith('v'))
|
||||
.map((release) => ({
|
||||
version: release.tag_name.replace(/^v/, ''),
|
||||
publishedAt: release.published_at ?? release.created_at,
|
||||
url: release.html_url,
|
||||
}))
|
||||
},
|
||||
{
|
||||
maxAge: CACHE_MAX_AGE,
|
||||
name: 'changelog-app-releases',
|
||||
getKey: () => 'changelog-app-releases',
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,596 @@
|
||||
import {
|
||||
type ActionState,
|
||||
deserializeActionStates,
|
||||
serializeActionStates,
|
||||
} from '@modrinth/moderation'
|
||||
|
||||
interface PersistedChecklistValue<T> {
|
||||
version: 1
|
||||
savedAt: string
|
||||
value: T
|
||||
}
|
||||
|
||||
export interface ModerationChecklistGeneratedMessageState {
|
||||
generated: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
const DB_NAME = 'modrinth-moderation'
|
||||
const DB_VERSION = 1
|
||||
const STORE_NAME = 'kv'
|
||||
const CHECKLIST_OPEN_KEY_PREFIX = 'show-moderation-checklist-'
|
||||
const STAGE_KEY_PREFIX = 'moderation-stage-'
|
||||
const ACTION_STATES_KEY_PREFIX = 'moderation-actions-'
|
||||
const TEXT_INPUTS_KEY_PREFIX = 'moderation-inputs-'
|
||||
const GENERATED_MESSAGE_KEY_PREFIX = 'moderation-generated-message-'
|
||||
const CHECKLIST_STATE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
|
||||
const CHECKLIST_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||
const CHECKLIST_CLEANUP_LAST_RUN_KEY = 'moderation-checklist-cleanup:last-run'
|
||||
const CHECKLIST_STATE_KEY_PREFIXES = [
|
||||
CHECKLIST_OPEN_KEY_PREFIX,
|
||||
STAGE_KEY_PREFIX,
|
||||
ACTION_STATES_KEY_PREFIX,
|
||||
TEXT_INPUTS_KEY_PREFIX,
|
||||
GENERATED_MESSAGE_KEY_PREFIX,
|
||||
]
|
||||
const indexedDbSaveChains = new Map<string, Promise<void>>()
|
||||
let checklistCleanupPromise: Promise<void> | null = null
|
||||
let checklistCleanupLastRunAt = 0
|
||||
|
||||
export function createEmptyGeneratedMessageState(): ModerationChecklistGeneratedMessageState {
|
||||
return {
|
||||
generated: false,
|
||||
message: '',
|
||||
}
|
||||
}
|
||||
|
||||
function hasIndexedDb(): boolean {
|
||||
return typeof window !== 'undefined' && typeof indexedDB !== 'undefined'
|
||||
}
|
||||
|
||||
function getLocalStorage(): Storage | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
try {
|
||||
return window.localStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB'))
|
||||
request.onblocked = () => reject(new Error('IndexedDB open request blocked'))
|
||||
})
|
||||
}
|
||||
|
||||
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
|
||||
})
|
||||
}
|
||||
|
||||
function wrapValue<T>(value: T, savedAt = new Date().toISOString()): PersistedChecklistValue<T> {
|
||||
return {
|
||||
version: 1,
|
||||
savedAt,
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object'
|
||||
}
|
||||
|
||||
function isPersistedValue<T>(
|
||||
value: unknown,
|
||||
isValue: (value: unknown) => value is T,
|
||||
): value is PersistedChecklistValue<T> {
|
||||
if (!isRecord(value)) return false
|
||||
if (value.version !== 1) return false
|
||||
if (typeof value.savedAt !== 'string') return false
|
||||
return isValue(value.value)
|
||||
}
|
||||
|
||||
function isBoolean(value: unknown): value is boolean {
|
||||
return typeof value === 'boolean'
|
||||
}
|
||||
|
||||
function isNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
}
|
||||
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === 'string'
|
||||
}
|
||||
|
||||
function isGeneratedMessageState(
|
||||
value: unknown,
|
||||
): value is ModerationChecklistGeneratedMessageState {
|
||||
if (!isRecord(value)) return false
|
||||
return typeof value.generated === 'boolean' && typeof value.message === 'string'
|
||||
}
|
||||
|
||||
function sanitizeStage(value: number): number {
|
||||
return Math.max(0, Math.trunc(value))
|
||||
}
|
||||
|
||||
function sanitizeTextInputs(value: unknown): Record<string, string> | null {
|
||||
if (!isRecord(value)) return null
|
||||
|
||||
const result: Record<string, string> = {}
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (typeof entry === 'string') {
|
||||
result[key] = entry
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function normalizeChecklistOpen(value: unknown): PersistedChecklistValue<boolean> | null {
|
||||
if (isPersistedValue(value, isBoolean)) return value
|
||||
if (isBoolean(value)) return wrapValue(value, '')
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeStage(value: unknown): PersistedChecklistValue<number> | null {
|
||||
if (isPersistedValue(value, isNumber)) {
|
||||
return {
|
||||
...value,
|
||||
value: sanitizeStage(value.value),
|
||||
}
|
||||
}
|
||||
if (isNumber(value)) return wrapValue(sanitizeStage(value), '')
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeActionStates(
|
||||
value: unknown,
|
||||
): PersistedChecklistValue<Record<string, ActionState>> | null {
|
||||
if (isRecord(value) && value.version === 1 && typeof value.savedAt === 'string') {
|
||||
if (isString(value.value)) {
|
||||
return {
|
||||
version: 1,
|
||||
savedAt: value.savedAt,
|
||||
value: deserializeActionStates(value.value),
|
||||
}
|
||||
}
|
||||
|
||||
if (isRecord(value.value)) {
|
||||
return {
|
||||
version: 1,
|
||||
savedAt: value.savedAt,
|
||||
value: deserializeActionStates(JSON.stringify(value.value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isString(value)) return wrapValue(deserializeActionStates(value), '')
|
||||
if (isRecord(value)) return wrapValue(deserializeActionStates(JSON.stringify(value)), '')
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeTextInputs(
|
||||
value: unknown,
|
||||
): PersistedChecklistValue<Record<string, string>> | null {
|
||||
if (isRecord(value) && value.version === 1 && typeof value.savedAt === 'string') {
|
||||
const textInputs = sanitizeTextInputs(value.value)
|
||||
if (textInputs) {
|
||||
return {
|
||||
version: 1,
|
||||
savedAt: value.savedAt,
|
||||
value: textInputs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const textInputs = sanitizeTextInputs(value)
|
||||
return textInputs ? wrapValue(textInputs, '') : null
|
||||
}
|
||||
|
||||
function normalizeGeneratedMessage(
|
||||
value: unknown,
|
||||
): PersistedChecklistValue<ModerationChecklistGeneratedMessageState> | null {
|
||||
if (isPersistedValue(value, isGeneratedMessageState)) return value
|
||||
if (isGeneratedMessageState(value)) return wrapValue(value, '')
|
||||
return null
|
||||
}
|
||||
|
||||
function savedAtTime<T>(state: PersistedChecklistValue<T>): number {
|
||||
const time = Date.parse(state.savedAt)
|
||||
return Number.isNaN(time) ? 0 : time
|
||||
}
|
||||
|
||||
function newestState<T>(
|
||||
first: PersistedChecklistValue<T> | null,
|
||||
second: PersistedChecklistValue<T> | null,
|
||||
): PersistedChecklistValue<T> | null {
|
||||
if (!first) return second
|
||||
if (!second) return first
|
||||
return savedAtTime(second) > savedAtTime(first) ? second : first
|
||||
}
|
||||
|
||||
function isChecklistStateKey(key: string): boolean {
|
||||
return CHECKLIST_STATE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix))
|
||||
}
|
||||
|
||||
function isStaleState<T>(
|
||||
state: PersistedChecklistValue<T>,
|
||||
now = Date.now(),
|
||||
maxAgeMs = CHECKLIST_STATE_MAX_AGE_MS,
|
||||
): boolean {
|
||||
const savedAt = savedAtTime(state)
|
||||
if (savedAt === 0) return false
|
||||
return now - savedAt > maxAgeMs
|
||||
}
|
||||
|
||||
function isStaleRawState(value: unknown, now = Date.now()): boolean {
|
||||
if (!isRecord(value)) return false
|
||||
if (value.version !== 1 || typeof value.savedAt !== 'string') return false
|
||||
|
||||
const savedAt = Date.parse(value.savedAt)
|
||||
if (Number.isNaN(savedAt)) return false
|
||||
return now - savedAt > CHECKLIST_STATE_MAX_AGE_MS
|
||||
}
|
||||
|
||||
async function loadFromIndexedDb<T>(
|
||||
key: string,
|
||||
normalize: (value: unknown) => PersistedChecklistValue<T> | null,
|
||||
): Promise<PersistedChecklistValue<T> | null> {
|
||||
if (!hasIndexedDb()) return null
|
||||
|
||||
const db = await openDatabase()
|
||||
try {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
return normalize(await requestToPromise(store.get(key)))
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupIndexedDb(now = Date.now()): Promise<void> {
|
||||
if (!hasIndexedDb()) return
|
||||
|
||||
const db = await openDatabase()
|
||||
try {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.openCursor()
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result
|
||||
if (!cursor) return
|
||||
|
||||
const key = typeof cursor.key === 'string' ? cursor.key : null
|
||||
if (key && isChecklistStateKey(key) && isStaleRawState(cursor.value, now)) {
|
||||
cursor.delete()
|
||||
}
|
||||
|
||||
cursor.continue()
|
||||
}
|
||||
request.onerror = () => reject(request.error ?? new Error('IndexedDB cursor failed'))
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
|
||||
})
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function saveToIndexedDb<T>(key: string, state: PersistedChecklistValue<T>): Promise<void> {
|
||||
if (!hasIndexedDb()) return
|
||||
|
||||
const db = await openDatabase()
|
||||
try {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).put(state, key)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
|
||||
})
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function clearIndexedDbKey(key: string): Promise<void> {
|
||||
if (!hasIndexedDb()) return
|
||||
|
||||
const db = await openDatabase()
|
||||
try {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).delete(key)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
|
||||
})
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function saveToIndexedDbInOrder<T>(
|
||||
key: string,
|
||||
state: PersistedChecklistValue<T>,
|
||||
): Promise<void> {
|
||||
const run = () => saveToIndexedDb(key, state)
|
||||
const result = (indexedDbSaveChains.get(key) ?? Promise.resolve()).then(run, run)
|
||||
indexedDbSaveChains.set(
|
||||
key,
|
||||
result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
async function clearIndexedDbKeyInOrder(key: string): Promise<void> {
|
||||
const run = () => clearIndexedDbKey(key)
|
||||
const result = (indexedDbSaveChains.get(key) ?? Promise.resolve()).then(run, run)
|
||||
indexedDbSaveChains.set(
|
||||
key,
|
||||
result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
function loadFromLocalStorage<T>(
|
||||
key: string,
|
||||
normalize: (value: unknown) => PersistedChecklistValue<T> | null,
|
||||
): PersistedChecklistValue<T> | null {
|
||||
const storage = getLocalStorage()
|
||||
if (!storage) return null
|
||||
|
||||
const raw = storage.getItem(key)
|
||||
if (!raw) return null
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
const state = normalize(parsed)
|
||||
if (state) return state
|
||||
} catch (error) {
|
||||
console.debug('Failed to parse moderation checklist state from localStorage:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
storage.removeItem(key)
|
||||
} catch (error) {
|
||||
console.debug('Failed to clear moderation checklist state from localStorage:', error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function safeSaveLocalStorage<T>(key: string, state: PersistedChecklistValue<T>): void {
|
||||
try {
|
||||
getLocalStorage()?.setItem(key, JSON.stringify(state))
|
||||
} catch (error) {
|
||||
console.debug('Failed to save moderation checklist state to localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function safeClearLocalStorage(key: string): void {
|
||||
try {
|
||||
getLocalStorage()?.removeItem(key)
|
||||
} catch (error) {
|
||||
console.debug('Failed to clear moderation checklist state from localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupLocalStorage(now = Date.now()): void {
|
||||
const storage = getLocalStorage()
|
||||
if (!storage) return
|
||||
|
||||
const keysToRemove: string[] = []
|
||||
for (let index = 0; index < storage.length; index++) {
|
||||
const key = storage.key(index)
|
||||
if (!key || !isChecklistStateKey(key)) continue
|
||||
|
||||
const raw = storage.getItem(key)
|
||||
if (!raw) continue
|
||||
|
||||
try {
|
||||
if (isStaleRawState(JSON.parse(raw), now)) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
} catch {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach((key) => safeClearLocalStorage(key))
|
||||
}
|
||||
|
||||
function scheduleStaleChecklistCleanup(): void {
|
||||
if (!import.meta.client || checklistCleanupPromise) return
|
||||
|
||||
const storage = getLocalStorage()
|
||||
const now = Date.now()
|
||||
const persistedLastRun = Number(storage?.getItem(CHECKLIST_CLEANUP_LAST_RUN_KEY) ?? 0)
|
||||
const lastRun = Math.max(
|
||||
checklistCleanupLastRunAt,
|
||||
Number.isFinite(persistedLastRun) ? persistedLastRun : 0,
|
||||
)
|
||||
if (Number.isFinite(lastRun) && now - lastRun < CHECKLIST_CLEANUP_INTERVAL_MS) return
|
||||
|
||||
checklistCleanupLastRunAt = now
|
||||
try {
|
||||
storage?.setItem(CHECKLIST_CLEANUP_LAST_RUN_KEY, String(now))
|
||||
} catch (error) {
|
||||
console.debug('Failed to save moderation checklist cleanup timestamp:', error)
|
||||
}
|
||||
|
||||
checklistCleanupPromise = (async () => {
|
||||
cleanupLocalStorage(now)
|
||||
try {
|
||||
await cleanupIndexedDb(now)
|
||||
} catch (error) {
|
||||
console.debug('Failed to cleanup stale moderation checklist state from IndexedDB:', error)
|
||||
}
|
||||
})().finally(() => {
|
||||
checklistCleanupPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
async function loadState<T>(
|
||||
key: string,
|
||||
normalize: (value: unknown) => PersistedChecklistValue<T> | null,
|
||||
touch = true,
|
||||
): Promise<T | null> {
|
||||
if (!import.meta.client) return null
|
||||
|
||||
scheduleStaleChecklistCleanup()
|
||||
|
||||
let indexedDbState: PersistedChecklistValue<T> | null = null
|
||||
try {
|
||||
indexedDbState = await loadFromIndexedDb(key, normalize)
|
||||
} catch (error) {
|
||||
console.debug('Failed to load moderation checklist state from IndexedDB:', error)
|
||||
}
|
||||
|
||||
let localStorageState: PersistedChecklistValue<T> | null = null
|
||||
try {
|
||||
localStorageState = loadFromLocalStorage(key, normalize)
|
||||
} catch (error) {
|
||||
console.debug('Failed to load moderation checklist state from localStorage:', error)
|
||||
}
|
||||
|
||||
const state = newestState(indexedDbState, localStorageState)
|
||||
if (!state) return null
|
||||
|
||||
if (isStaleState(state)) {
|
||||
await clearState(key)
|
||||
return null
|
||||
}
|
||||
|
||||
if (touch) {
|
||||
void saveState(key, state.value)
|
||||
}
|
||||
|
||||
return state.value
|
||||
}
|
||||
|
||||
async function saveState<T>(key: string, value: T): Promise<void> {
|
||||
if (!import.meta.client) return
|
||||
|
||||
scheduleStaleChecklistCleanup()
|
||||
|
||||
const state = wrapValue(value)
|
||||
safeSaveLocalStorage(key, state)
|
||||
|
||||
if (hasIndexedDb()) {
|
||||
try {
|
||||
await saveToIndexedDbInOrder(key, state)
|
||||
} catch (error) {
|
||||
console.debug('Failed to save moderation checklist state to IndexedDB:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function clearState(key: string): Promise<void> {
|
||||
if (!import.meta.client) return
|
||||
|
||||
safeClearLocalStorage(key)
|
||||
if (hasIndexedDb()) {
|
||||
try {
|
||||
await clearIndexedDbKeyInOrder(key)
|
||||
} catch (error) {
|
||||
console.debug('Failed to clear moderation checklist state from IndexedDB:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadChecklistOpenState(projectId: string): Promise<boolean | null> {
|
||||
return loadState(`${CHECKLIST_OPEN_KEY_PREFIX}${projectId}`, normalizeChecklistOpen, false)
|
||||
}
|
||||
|
||||
export async function saveChecklistOpenState(projectId: string, open: boolean): Promise<void> {
|
||||
await saveState(`${CHECKLIST_OPEN_KEY_PREFIX}${projectId}`, open)
|
||||
}
|
||||
|
||||
export async function loadChecklistStage(projectSlug: string): Promise<number | null> {
|
||||
return loadState(`${STAGE_KEY_PREFIX}${projectSlug}`, normalizeStage)
|
||||
}
|
||||
|
||||
export async function saveChecklistStage(projectSlug: string, stage: number): Promise<void> {
|
||||
await saveState(`${STAGE_KEY_PREFIX}${projectSlug}`, sanitizeStage(stage))
|
||||
}
|
||||
|
||||
export async function loadChecklistActionStates(
|
||||
projectSlug: string,
|
||||
): Promise<Record<string, ActionState>> {
|
||||
const actionStates =
|
||||
(await loadState(`${ACTION_STATES_KEY_PREFIX}${projectSlug}`, normalizeActionStates, false)) ??
|
||||
{}
|
||||
if (Object.keys(actionStates).length > 0) {
|
||||
void saveChecklistActionStates(projectSlug, actionStates)
|
||||
}
|
||||
return actionStates
|
||||
}
|
||||
|
||||
export async function saveChecklistActionStates(
|
||||
projectSlug: string,
|
||||
actionStates: Record<string, ActionState>,
|
||||
): Promise<void> {
|
||||
await saveState(`${ACTION_STATES_KEY_PREFIX}${projectSlug}`, serializeActionStates(actionStates))
|
||||
}
|
||||
|
||||
export async function loadChecklistTextInputs(
|
||||
projectSlug: string,
|
||||
): Promise<Record<string, string>> {
|
||||
return (await loadState(`${TEXT_INPUTS_KEY_PREFIX}${projectSlug}`, normalizeTextInputs)) ?? {}
|
||||
}
|
||||
|
||||
export async function saveChecklistTextInputs(
|
||||
projectSlug: string,
|
||||
textInputs: Record<string, string>,
|
||||
): Promise<void> {
|
||||
await saveState(`${TEXT_INPUTS_KEY_PREFIX}${projectSlug}`, textInputs)
|
||||
}
|
||||
|
||||
export async function clearChecklistProgressState(projectSlug: string): Promise<void> {
|
||||
await Promise.all([
|
||||
clearState(`${STAGE_KEY_PREFIX}${projectSlug}`),
|
||||
clearState(`${ACTION_STATES_KEY_PREFIX}${projectSlug}`),
|
||||
clearState(`${TEXT_INPUTS_KEY_PREFIX}${projectSlug}`),
|
||||
])
|
||||
}
|
||||
|
||||
export async function loadGeneratedMessageState(
|
||||
projectSlug: string,
|
||||
): Promise<ModerationChecklistGeneratedMessageState> {
|
||||
return (
|
||||
(await loadState(`${GENERATED_MESSAGE_KEY_PREFIX}${projectSlug}`, normalizeGeneratedMessage)) ??
|
||||
createEmptyGeneratedMessageState()
|
||||
)
|
||||
}
|
||||
|
||||
export async function saveGeneratedMessageState(
|
||||
projectSlug: string,
|
||||
state: ModerationChecklistGeneratedMessageState,
|
||||
): Promise<void> {
|
||||
await saveState(`${GENERATED_MESSAGE_KEY_PREFIX}${projectSlug}`, state)
|
||||
}
|
||||
|
||||
export async function clearGeneratedMessageState(projectSlug: string): Promise<void> {
|
||||
await clearState(`${GENERATED_MESSAGE_KEY_PREFIX}${projectSlug}`)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
export interface PersistedModerationQueueState {
|
||||
version: 1
|
||||
savedAt: string
|
||||
currentQueue: {
|
||||
items: string[]
|
||||
total: number
|
||||
completed: number
|
||||
skipped: number
|
||||
lastUpdated: string
|
||||
}
|
||||
isQueueMode: boolean
|
||||
}
|
||||
|
||||
const DB_NAME = 'modrinth-moderation'
|
||||
const DB_VERSION = 1
|
||||
const STORE_NAME = 'kv'
|
||||
export const MODERATION_QUEUE_KEY = 'moderation-queue:v1'
|
||||
|
||||
function hasIndexedDb(): boolean {
|
||||
return typeof window !== 'undefined' && typeof indexedDB !== 'undefined'
|
||||
}
|
||||
|
||||
function getLocalStorage(): Storage | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
try {
|
||||
return window.localStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
|
||||
}
|
||||
|
||||
function isPersistedStateCandidate(value: unknown): value is PersistedModerationQueueState {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
|
||||
const candidate = value as PersistedModerationQueueState
|
||||
if (candidate.version !== 1) return false
|
||||
if (typeof candidate.savedAt !== 'string') return false
|
||||
if (typeof candidate.isQueueMode !== 'boolean') return false
|
||||
|
||||
const queue = candidate.currentQueue
|
||||
if (!queue || typeof queue !== 'object') return false
|
||||
if (!isStringArray(queue.items)) return false
|
||||
if (typeof queue.total !== 'number' || Number.isNaN(queue.total)) return false
|
||||
if (typeof queue.completed !== 'number' || Number.isNaN(queue.completed)) return false
|
||||
if (typeof queue.skipped !== 'number' || Number.isNaN(queue.skipped)) return false
|
||||
if (typeof queue.lastUpdated !== 'string') return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function savedAtTime(state: PersistedModerationQueueState): number {
|
||||
const time = Date.parse(state.savedAt)
|
||||
return Number.isNaN(time) ? 0 : time
|
||||
}
|
||||
|
||||
function newestState(
|
||||
first: PersistedModerationQueueState | null,
|
||||
second: PersistedModerationQueueState | null,
|
||||
): PersistedModerationQueueState | null {
|
||||
if (!first) return second
|
||||
if (!second) return first
|
||||
return savedAtTime(second) > savedAtTime(first) ? second : first
|
||||
}
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB'))
|
||||
request.onblocked = () => reject(new Error('IndexedDB open request blocked'))
|
||||
})
|
||||
}
|
||||
|
||||
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
|
||||
})
|
||||
}
|
||||
|
||||
async function loadFromIndexedDb(): Promise<PersistedModerationQueueState | null> {
|
||||
if (!hasIndexedDb()) return null
|
||||
|
||||
const db = await openDatabase()
|
||||
try {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const raw = await requestToPromise(store.get(MODERATION_QUEUE_KEY))
|
||||
if (!isPersistedStateCandidate(raw)) return null
|
||||
|
||||
return raw
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function saveToIndexedDb(state: PersistedModerationQueueState): Promise<void> {
|
||||
if (!hasIndexedDb()) return
|
||||
|
||||
const db = await openDatabase()
|
||||
try {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).put(state, MODERATION_QUEUE_KEY)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
|
||||
})
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function clearIndexedDb(): Promise<void> {
|
||||
if (!hasIndexedDb()) return
|
||||
|
||||
const db = await openDatabase()
|
||||
try {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).delete(MODERATION_QUEUE_KEY)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
|
||||
})
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromLocalStorage(): PersistedModerationQueueState | null {
|
||||
const storage = getLocalStorage()
|
||||
if (!storage) return null
|
||||
|
||||
const raw = storage.getItem(MODERATION_QUEUE_KEY)
|
||||
if (!raw) return null
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (isPersistedStateCandidate(parsed)) return parsed
|
||||
} catch (error) {
|
||||
console.debug('Failed to parse moderation queue from localStorage:', error)
|
||||
}
|
||||
|
||||
safeClearLocalStorage()
|
||||
return null
|
||||
}
|
||||
|
||||
function saveToLocalStorage(state: PersistedModerationQueueState): void {
|
||||
const storage = getLocalStorage()
|
||||
if (!storage) return
|
||||
storage.setItem(MODERATION_QUEUE_KEY, JSON.stringify(state))
|
||||
}
|
||||
|
||||
function clearLocalStorage(): void {
|
||||
const storage = getLocalStorage()
|
||||
if (!storage) return
|
||||
storage.removeItem(MODERATION_QUEUE_KEY)
|
||||
}
|
||||
|
||||
function safeClearLocalStorage(): void {
|
||||
try {
|
||||
clearLocalStorage()
|
||||
} catch (error) {
|
||||
console.debug('Failed to clear moderation queue from localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function safeSaveLocalStorage(state: PersistedModerationQueueState): void {
|
||||
try {
|
||||
saveToLocalStorage(state)
|
||||
} catch (error) {
|
||||
console.debug('Failed to save moderation queue to localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadQueueState(): Promise<PersistedModerationQueueState | null> {
|
||||
if (!import.meta.client) return null
|
||||
|
||||
let indexedDbState: PersistedModerationQueueState | null = null
|
||||
try {
|
||||
indexedDbState = await loadFromIndexedDb()
|
||||
} catch (error) {
|
||||
console.debug('Failed to load moderation queue from IndexedDB:', error)
|
||||
}
|
||||
|
||||
let localStorageState: PersistedModerationQueueState | null = null
|
||||
try {
|
||||
localStorageState = loadFromLocalStorage()
|
||||
} catch (error) {
|
||||
console.debug('Failed to load moderation queue from localStorage:', error)
|
||||
}
|
||||
|
||||
return newestState(indexedDbState, localStorageState)
|
||||
}
|
||||
|
||||
export async function saveQueueState(state: PersistedModerationQueueState): Promise<void> {
|
||||
if (!import.meta.client) return
|
||||
|
||||
if (hasIndexedDb()) {
|
||||
try {
|
||||
await saveToIndexedDb(state)
|
||||
safeSaveLocalStorage(state)
|
||||
return
|
||||
} catch (error) {
|
||||
console.debug(
|
||||
'Failed to save moderation queue to IndexedDB, using localStorage fallback:',
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
safeSaveLocalStorage(state)
|
||||
}
|
||||
|
||||
export async function clearQueueState(): Promise<void> {
|
||||
if (!import.meta.client) return
|
||||
|
||||
if (hasIndexedDb()) {
|
||||
try {
|
||||
await clearIndexedDb()
|
||||
} catch (error) {
|
||||
console.debug('Failed to clear moderation queue from IndexedDB:', error)
|
||||
}
|
||||
}
|
||||
|
||||
safeClearLocalStorage()
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client'
|
||||
import { injectModrinthClient } from '@modrinth/ui'
|
||||
import { computed, proxyRefs, ref } from 'vue'
|
||||
|
||||
import {
|
||||
loadQueueState,
|
||||
type PersistedModerationQueueState,
|
||||
saveQueueState,
|
||||
} from './moderation-queue-storage.ts'
|
||||
|
||||
export interface ModerationQueue {
|
||||
items: string[]
|
||||
total: number
|
||||
completed: number
|
||||
skipped: number
|
||||
lastUpdated: Date
|
||||
}
|
||||
|
||||
export type LockedByUser = Labrinth.Moderation.Internal.LockedByUser
|
||||
export type LockStatusResponse = Labrinth.Moderation.Internal.LockStatusResponse
|
||||
export type LockAcquireResponse = Labrinth.Moderation.Internal.LockAcquireResponse
|
||||
|
||||
export interface ModerationQueueService {
|
||||
currentQueue: ModerationQueue
|
||||
currentLock: { projectId: string; lockedAt: Date } | null
|
||||
isQueueMode: boolean
|
||||
hydrated: boolean
|
||||
ready: Promise<void>
|
||||
|
||||
queueLength: number
|
||||
hasItems: boolean
|
||||
progress: number
|
||||
|
||||
setQueue(projectIds: string[]): Promise<void>
|
||||
setSingleProject(projectId: string): Promise<void>
|
||||
completeCurrentProject(projectId: string, status?: 'completed' | 'skipped'): Promise<boolean>
|
||||
getCurrentProjectId(): string | null
|
||||
resetQueue(): Promise<void>
|
||||
|
||||
acquireLock(projectId: string): Promise<LockAcquireResponse>
|
||||
overrideLock(projectId: string): Promise<LockAcquireResponse>
|
||||
releaseLock(projectId: string): Promise<boolean>
|
||||
checkLock(projectId: string): Promise<LockStatusResponse>
|
||||
refreshLock(): Promise<LockAcquireResponse>
|
||||
}
|
||||
|
||||
const EMPTY_QUEUE: ModerationQueue = {
|
||||
items: [],
|
||||
total: 0,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
lastUpdated: new Date(),
|
||||
}
|
||||
|
||||
function createEmptyQueue(): ModerationQueue {
|
||||
return { ...EMPTY_QUEUE, lastUpdated: new Date(), items: [] }
|
||||
}
|
||||
|
||||
function sanitizeQueue(raw: PersistedModerationQueueState['currentQueue']): ModerationQueue {
|
||||
const lastUpdated = new Date(raw.lastUpdated)
|
||||
const items = raw.items.filter((id): id is string => typeof id === 'string')
|
||||
const completed = Number.isFinite(raw.completed) ? Math.max(Math.trunc(raw.completed), 0) : 0
|
||||
const skipped = Number.isFinite(raw.skipped) ? Math.max(Math.trunc(raw.skipped), 0) : 0
|
||||
const minimumTotal = items.length + completed + skipped
|
||||
const total = Number.isFinite(raw.total)
|
||||
? Math.max(Math.trunc(raw.total), minimumTotal)
|
||||
: minimumTotal
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
completed,
|
||||
skipped,
|
||||
lastUpdated: Number.isNaN(lastUpdated.getTime()) ? new Date() : lastUpdated,
|
||||
}
|
||||
}
|
||||
|
||||
function persistedPayload(
|
||||
queue: ModerationQueue,
|
||||
isQueueMode: boolean,
|
||||
): PersistedModerationQueueState {
|
||||
return {
|
||||
version: 1,
|
||||
savedAt: new Date().toISOString(),
|
||||
currentQueue: {
|
||||
items: [...queue.items],
|
||||
total: queue.total,
|
||||
completed: queue.completed,
|
||||
skipped: queue.skipped,
|
||||
lastUpdated: queue.lastUpdated.toISOString(),
|
||||
},
|
||||
isQueueMode,
|
||||
}
|
||||
}
|
||||
|
||||
function createModerationQueueState(client: AbstractModrinthClient = injectModrinthClient()) {
|
||||
const currentQueue = ref(createEmptyQueue())
|
||||
const currentLock = ref<{ projectId: string; lockedAt: Date } | null>(null)
|
||||
const isQueueMode = ref(false)
|
||||
const hydrated = ref(false)
|
||||
|
||||
const queueLength = computed(() => currentQueue.value.items.length)
|
||||
const hasItems = computed(() => currentQueue.value.items.length > 0)
|
||||
const progress = computed(() => {
|
||||
if (currentQueue.value.total === 0) return 0
|
||||
return (currentQueue.value.completed + currentQueue.value.skipped) / currentQueue.value.total
|
||||
})
|
||||
let mutationChain = Promise.resolve()
|
||||
|
||||
const ready = (async () => {
|
||||
if (import.meta.server) {
|
||||
hydrated.value = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const persisted = await loadQueueState()
|
||||
if (persisted?.currentQueue) {
|
||||
currentQueue.value = sanitizeQueue(persisted.currentQueue)
|
||||
isQueueMode.value = persisted.isQueueMode
|
||||
}
|
||||
} catch {
|
||||
currentQueue.value = createEmptyQueue()
|
||||
isQueueMode.value = false
|
||||
} finally {
|
||||
hydrated.value = true
|
||||
}
|
||||
})()
|
||||
|
||||
async function persist(): Promise<void> {
|
||||
if (import.meta.server) return
|
||||
await saveQueueState(persistedPayload(currentQueue.value, isQueueMode.value))
|
||||
}
|
||||
|
||||
async function withMutation<T>(callback: () => T): Promise<T> {
|
||||
const run = async () => {
|
||||
await ready
|
||||
const value = callback()
|
||||
await persist()
|
||||
return value
|
||||
}
|
||||
|
||||
const result = mutationChain.then(run, run)
|
||||
mutationChain = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
function setQueueState(items: string[], mode: boolean) {
|
||||
isQueueMode.value = mode
|
||||
currentQueue.value = {
|
||||
items: [...items],
|
||||
total: items.length,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
lastUpdated: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
async function setQueue(projectIds: string[]): Promise<void> {
|
||||
await withMutation(() => {
|
||||
setQueueState(projectIds, true)
|
||||
})
|
||||
}
|
||||
|
||||
async function setSingleProject(projectId: string): Promise<void> {
|
||||
await withMutation(() => {
|
||||
setQueueState([projectId], false)
|
||||
})
|
||||
}
|
||||
|
||||
async function completeCurrentProject(
|
||||
projectId: string,
|
||||
status: 'completed' | 'skipped' = 'completed',
|
||||
): Promise<boolean> {
|
||||
return withMutation(() => {
|
||||
if (!currentQueue.value.items.includes(projectId)) {
|
||||
return currentQueue.value.items.length > 0
|
||||
}
|
||||
|
||||
if (status === 'completed') {
|
||||
currentQueue.value.completed++
|
||||
} else {
|
||||
currentQueue.value.skipped++
|
||||
}
|
||||
|
||||
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
|
||||
currentQueue.value.lastUpdated = new Date()
|
||||
|
||||
return currentQueue.value.items.length > 0
|
||||
})
|
||||
}
|
||||
|
||||
function getCurrentProjectId(): string | null {
|
||||
return currentQueue.value.items[0] || null
|
||||
}
|
||||
|
||||
async function resetQueue(): Promise<void> {
|
||||
await withMutation(() => {
|
||||
isQueueMode.value = false
|
||||
currentQueue.value = createEmptyQueue()
|
||||
})
|
||||
}
|
||||
|
||||
async function acquireLock(projectId: string): Promise<LockAcquireResponse> {
|
||||
await ready
|
||||
|
||||
try {
|
||||
const response = await client.labrinth.moderation_internal.acquireLock(projectId)
|
||||
|
||||
if (response.success) {
|
||||
currentLock.value = { projectId, lockedAt: new Date() }
|
||||
} else if (currentLock.value?.projectId === projectId) {
|
||||
currentLock.value = null
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to acquire moderation lock:', error)
|
||||
return { success: false, is_own_lock: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function overrideLock(projectId: string): Promise<LockAcquireResponse> {
|
||||
await ready
|
||||
|
||||
try {
|
||||
const response = await client.labrinth.moderation_internal.overrideLock(projectId)
|
||||
|
||||
if (response.success) {
|
||||
currentLock.value = { projectId, lockedAt: new Date() }
|
||||
} else if (currentLock.value?.projectId === projectId) {
|
||||
currentLock.value = null
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to override moderation lock:', error)
|
||||
return { success: false, is_own_lock: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseLock(projectId: string): Promise<boolean> {
|
||||
await ready
|
||||
|
||||
try {
|
||||
const response = await client.labrinth.moderation_internal.releaseLock(projectId)
|
||||
|
||||
if (currentLock.value?.projectId === projectId) {
|
||||
currentLock.value = null
|
||||
}
|
||||
|
||||
return response.success
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkLock(projectId: string): Promise<LockStatusResponse> {
|
||||
await ready
|
||||
|
||||
try {
|
||||
const response = await client.labrinth.moderation_internal.checkLock(projectId)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to check moderation lock:', error)
|
||||
return { locked: false, is_own_lock: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLock(): Promise<LockAcquireResponse> {
|
||||
await ready
|
||||
|
||||
if (!currentLock.value) return { success: false, is_own_lock: false }
|
||||
|
||||
try {
|
||||
const response = await acquireLock(currentLock.value.projectId)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh moderation lock:', error)
|
||||
currentLock.value = null
|
||||
return { success: false, is_own_lock: false }
|
||||
}
|
||||
}
|
||||
|
||||
return proxyRefs({
|
||||
currentQueue,
|
||||
currentLock,
|
||||
isQueueMode,
|
||||
hydrated,
|
||||
ready,
|
||||
|
||||
queueLength,
|
||||
hasItems,
|
||||
progress,
|
||||
|
||||
setQueue,
|
||||
setSingleProject,
|
||||
completeCurrentProject,
|
||||
getCurrentProjectId,
|
||||
resetQueue,
|
||||
|
||||
acquireLock,
|
||||
overrideLock,
|
||||
releaseLock,
|
||||
checkLock,
|
||||
refreshLock,
|
||||
}) as ModerationQueueService
|
||||
}
|
||||
|
||||
export const createModerationQueueService = createModerationQueueState
|
||||
|
||||
const moderationQueueServices = new WeakMap<object, ModerationQueueService>()
|
||||
|
||||
export function useModerationQueue(): ModerationQueueService {
|
||||
const nuxtApp = useNuxtApp()
|
||||
const existingService = moderationQueueServices.get(nuxtApp)
|
||||
if (existingService) return existingService
|
||||
|
||||
const service = createModerationQueueService()
|
||||
moderationQueueServices.set(nuxtApp, service)
|
||||
return service
|
||||
}
|
||||
@@ -1,232 +1,18 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import type {
|
||||
LockAcquireResponse,
|
||||
LockedByUser,
|
||||
LockStatusResponse,
|
||||
ModerationQueue,
|
||||
ModerationQueueService,
|
||||
} from '~/services/moderation-queue.ts'
|
||||
import { useModerationQueue } from '~/services/moderation-queue.ts'
|
||||
|
||||
export interface ModerationQueue {
|
||||
items: string[]
|
||||
total: number
|
||||
completed: number
|
||||
skipped: number
|
||||
lastUpdated: Date
|
||||
export type {
|
||||
LockAcquireResponse,
|
||||
LockedByUser,
|
||||
LockStatusResponse,
|
||||
ModerationQueue,
|
||||
ModerationQueueService,
|
||||
}
|
||||
|
||||
export interface LockedByUser {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url?: string
|
||||
}
|
||||
|
||||
export interface LockStatusResponse {
|
||||
locked: boolean
|
||||
is_own_lock: boolean
|
||||
locked_by?: LockedByUser
|
||||
locked_at?: string
|
||||
expires_at?: string
|
||||
expired?: boolean
|
||||
}
|
||||
|
||||
export interface LockAcquireResponse {
|
||||
success: boolean
|
||||
is_own_lock: boolean
|
||||
locked_by?: LockedByUser
|
||||
locked_at?: string
|
||||
expires_at?: string
|
||||
expired?: boolean
|
||||
}
|
||||
|
||||
const EMPTY_QUEUE: Partial<ModerationQueue> = {
|
||||
items: [],
|
||||
|
||||
// TODO: Consider some form of displaying this in the checklist, maybe at the end
|
||||
total: 0,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
}
|
||||
|
||||
function createEmptyQueue(): ModerationQueue {
|
||||
return { ...EMPTY_QUEUE, lastUpdated: new Date() } as ModerationQueue
|
||||
}
|
||||
|
||||
export const useModerationStore = defineStore(
|
||||
'moderation',
|
||||
() => {
|
||||
const currentQueue = ref<ModerationQueue>(createEmptyQueue())
|
||||
const currentLock = ref<{ projectId: string; lockedAt: Date } | null>(null)
|
||||
const isQueueMode = ref(false)
|
||||
|
||||
const queueLength = computed(() => currentQueue.value.items.length)
|
||||
const hasItems = computed(() => currentQueue.value.items.length > 0)
|
||||
const progress = computed(() => {
|
||||
if (currentQueue.value.total === 0) return 0
|
||||
return (currentQueue.value.completed + currentQueue.value.skipped) / currentQueue.value.total
|
||||
})
|
||||
|
||||
function setQueue(projectIDs: string[]) {
|
||||
isQueueMode.value = true
|
||||
currentQueue.value = {
|
||||
items: [...projectIDs],
|
||||
total: projectIDs.length,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
lastUpdated: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
function setSingleProject(projectId: string) {
|
||||
isQueueMode.value = false
|
||||
currentQueue.value = {
|
||||
items: [projectId],
|
||||
total: 1,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
lastUpdated: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
function completeCurrentProject(
|
||||
projectId: string,
|
||||
status: 'completed' | 'skipped' = 'completed',
|
||||
) {
|
||||
if (status === 'completed') {
|
||||
currentQueue.value.completed++
|
||||
} else {
|
||||
currentQueue.value.skipped++
|
||||
}
|
||||
|
||||
currentQueue.value.items = currentQueue.value.items.filter((id: string) => id !== projectId)
|
||||
currentQueue.value.lastUpdated = new Date()
|
||||
|
||||
return currentQueue.value.items.length > 0
|
||||
}
|
||||
|
||||
function getCurrentProjectId(): string | null {
|
||||
return currentQueue.value.items[0] || null
|
||||
}
|
||||
|
||||
function resetQueue() {
|
||||
isQueueMode.value = false
|
||||
currentQueue.value = createEmptyQueue()
|
||||
}
|
||||
|
||||
async function acquireLock(projectId: string): Promise<LockAcquireResponse> {
|
||||
try {
|
||||
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
|
||||
method: 'POST',
|
||||
internal: true,
|
||||
})) as LockAcquireResponse
|
||||
|
||||
if (response.success) {
|
||||
currentLock.value = { projectId, lockedAt: new Date() }
|
||||
} else if (currentLock.value?.projectId === projectId) {
|
||||
// We were outbid or our lock expired — clear stale state
|
||||
currentLock.value = null
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to acquire moderation lock:', error)
|
||||
return { success: false, is_own_lock: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function overrideLock(projectId: string): Promise<LockAcquireResponse> {
|
||||
try {
|
||||
const response = (await useBaseFetch(`moderation/lock/${projectId}/override`, {
|
||||
method: 'POST',
|
||||
internal: true,
|
||||
})) as LockAcquireResponse
|
||||
|
||||
if (response.success) {
|
||||
currentLock.value = { projectId, lockedAt: new Date() }
|
||||
} else if (currentLock.value?.projectId === projectId) {
|
||||
currentLock.value = null
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to override moderation lock:', error)
|
||||
return { success: false, is_own_lock: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseLock(projectId: string): Promise<boolean> {
|
||||
try {
|
||||
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
|
||||
method: 'DELETE',
|
||||
internal: true,
|
||||
})) as { success: boolean }
|
||||
|
||||
if (currentLock.value?.projectId === projectId) {
|
||||
currentLock.value = null
|
||||
}
|
||||
|
||||
return response.success
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkLock(projectId: string): Promise<LockStatusResponse> {
|
||||
try {
|
||||
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
|
||||
method: 'GET',
|
||||
internal: true,
|
||||
})) as LockStatusResponse
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to check moderation lock:', error)
|
||||
// Return unlocked status on error so moderation can proceed
|
||||
return { locked: false, is_own_lock: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLock(): Promise<LockAcquireResponse> {
|
||||
if (!currentLock.value) return { success: false, is_own_lock: false }
|
||||
|
||||
try {
|
||||
const response = await acquireLock(currentLock.value.projectId)
|
||||
// acquireLock already clears currentLock on failure
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh moderation lock:', error)
|
||||
currentLock.value = null
|
||||
return { success: false, is_own_lock: false }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentQueue,
|
||||
currentLock,
|
||||
isQueueMode,
|
||||
queueLength,
|
||||
hasItems,
|
||||
progress,
|
||||
setQueue,
|
||||
setSingleProject,
|
||||
completeCurrentProject,
|
||||
getCurrentProjectId,
|
||||
resetQueue,
|
||||
acquireLock,
|
||||
overrideLock,
|
||||
releaseLock,
|
||||
checkLock,
|
||||
refreshLock,
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: 'moderation-store',
|
||||
// Only persist queue state — currentLock is always revalidated on mount
|
||||
paths: ['currentQueue', 'isQueueMode'],
|
||||
serializer: {
|
||||
serialize: JSON.stringify,
|
||||
deserialize: (value: string) => {
|
||||
const parsed = JSON.parse(value)
|
||||
if (parsed.currentQueue?.lastUpdated) {
|
||||
parsed.currentQueue.lastUpdated = new Date(parsed.currentQueue.lastUpdated)
|
||||
}
|
||||
return parsed
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
export const useModerationStore = useModerationQueue
|
||||
|
||||
@@ -11,7 +11,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
>
|
||||
<Heading as="h1" class="mb-2 text-2xl font-bold"> New sign-in method added </Heading>
|
||||
|
||||
<Text class="text-muted text-base">Hi {user.name},</Text>
|
||||
<Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
|
||||
<Text class="text-muted text-base">
|
||||
Your {authprovider.name} account has been connected and you can now use it to sign in to your
|
||||
Modrinth account.
|
||||
|
||||
@@ -11,7 +11,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
>
|
||||
<Heading as="h1" class="mb-2 text-2xl font-bold"> Sign-in method removed</Heading>
|
||||
|
||||
<Text class="text-muted text-base">Hi {user.name},</Text>
|
||||
<Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
|
||||
<Text class="text-muted text-base">
|
||||
Your <b>{authprovider.name}</b> account has been disconnected and you can no longer use it to
|
||||
sign in to your Modrinth account.
|
||||
|
||||
@@ -11,7 +11,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
>
|
||||
<Heading as="h1" class="mb-2 text-2xl font-bold"> Your email has been changed </Heading>
|
||||
|
||||
<Text class="text-muted text-base">Hi {user.name},</Text>
|
||||
<Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
|
||||
<Text class="text-muted text-base">
|
||||
At your request, we've successfully updated your Modrinth account's email to
|
||||
{emailchanged.new_email}.
|
||||
|
||||
@@ -11,7 +11,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
>
|
||||
<Heading as="h1" class="mb-2 text-2xl font-bold"> Sign in from new device </Heading>
|
||||
|
||||
<Text class="text-muted text-base">Hi {user.name},</Text>
|
||||
<Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
|
||||
<Text class="text-muted text-base">
|
||||
We noticed that your account was just signed into from a new device or location. If this was
|
||||
you, you can safely ignore this email.
|
||||
|
||||
@@ -13,7 +13,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
A new personal access token has been created
|
||||
</Heading>
|
||||
|
||||
<Text class="text-muted text-base">Hi {user.name},</Text>
|
||||
<Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
|
||||
<Text class="text-muted text-base">
|
||||
A new personal access token, <b>{newpat.token_name}</b>, has been added to your account.
|
||||
</Text>
|
||||
|
||||
@@ -11,7 +11,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
>
|
||||
<Heading as="h1" class="mb-2 text-2xl font-bold"> Your password has been changed </Heading>
|
||||
|
||||
<Text class="text-muted text-base">Hi {user.name},</Text>
|
||||
<Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
|
||||
<Text class="text-muted text-base"> Your password has been changed on your account. </Text>
|
||||
<Text class="text-muted text-base">
|
||||
If you did not make this change, please contact us immediately through our
|
||||
|
||||
@@ -11,7 +11,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
>
|
||||
<Heading as="h1" class="mb-2 text-2xl font-bold"> Your password has been removed </Heading>
|
||||
|
||||
<Text class="text-muted text-base">Hi {user.name},</Text>
|
||||
<Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
|
||||
<Text class="text-muted text-base">
|
||||
At your request, your password has been removed from your account. You must now use a linked
|
||||
authentication provider (such as your {passremoved.provider} account) to log into your
|
||||
|
||||
@@ -14,7 +14,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
Payment failed for {paymentfailed.service}
|
||||
</Heading>
|
||||
|
||||
<Text class="text-muted text-base">Hi {user.name},</Text>
|
||||
<Text class="text-muted text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
|
||||
<Text class="text-muted text-base">
|
||||
Our attempt to collect payment for {paymentfailed.amount} from the payment card on file was
|
||||
unsuccessful. Please update your billing settings to avoid suspension of your service.
|
||||
|
||||
@@ -14,7 +14,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
|
||||
>
|
||||
<Heading as="h1" class="mb-2 text-2xl font-bold">Revenue available to withdraw!</Heading>
|
||||
|
||||
<Text class="text-base">Hi {user.name},</Text>
|
||||
<Text class="text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
|
||||
|
||||
<Text class="text-base">
|
||||
The {payout.amount} earned during {payout.period} has been processed and is now available to
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user