Compare commits

...

41 Commits

Author SHA1 Message Date
Josiah Glosson
2774cdca76 Merge branch 'main' into app-updater-rework
# Conflicts:
#	packages/app-lib/src/state/friends.rs
#	packages/app-lib/src/util/fetch.rs
2025-07-22 15:39:52 -05:00
Alejandro González
d4516d3527 feat(app): configurable Modrinth endpoints through .env files (#4015) 2025-07-21 22:55:57 +00:00
Josiah Glosson
87de47fe5e Use rust-lld linker on MSVC Windows (#4042)
The latest version of MSVC fails when linking labrinth, making now a perfect opportunity to switch over to the rust-lld linker instead.
2025-07-21 22:35:05 +00:00
Josiah Glosson
3b6a9dde0f Run intl:extract 2025-07-21 10:56:08 -05:00
Josiah Glosson
a1e0c134a0 Merge branch 'main' into app-updater-rework 2025-07-21 10:53:00 -05:00
Emma Alexia
7d76fe1b6a Add more info about last attempts to admin billing dashboard (#4029) 2025-07-21 08:35:36 +00:00
Prospector
ae25a15abd Update changelog 2025-07-19 15:17:39 -07:00
Prospector
0f755b94ce Revert "Author Validation Improvements (#3970)" (#4024)
This reverts commit 44267619b6.
2025-07-19 22:04:47 +00:00
Emma Alexia
bcf46d440b Count failed payments as "open" charges (#4013)
This allows people to cancel failed payments, currently it fails with error "There is no open charge for this subscription"
2025-07-19 14:33:37 +00:00
Josiah Glosson
526561f2de Add --color to intl:extract verification (#4023) 2025-07-19 12:42:17 +00:00
IMB11
0310cc52d0 fix: hide modal header & add "Hide update reminder" button w/ tooltip 2025-07-17 11:37:17 +01:00
Josiah Glosson
071e2b58b3 Fix lint 2025-07-16 19:04:24 -05:00
Prospector
30e93e0880 lint 2025-07-16 15:25:38 -07:00
Prospector
2c90f1c142 UI tweaks 2025-07-16 15:08:21 -07:00
Josiah Glosson
83bd4dde45 Request the update size with HEAD instead of GET 2025-07-15 20:36:21 -05:00
Josiah Glosson
221c26d613 Merge branch 'main' into app-updater-rework 2025-07-15 20:31:11 -05:00
Josiah Glosson
7cc39cb54d Fix build on Mac 2025-07-15 20:28:34 -05:00
Josiah Glosson
80e0f84b62 Use single LAUNCHER_USER_AGENT constant for all user agents 2025-07-15 20:25:37 -05:00
Josiah Glosson
1e934312a4 Update completion toasts (#3978) 2025-07-12 21:05:17 +01:00
Josiah Glosson
6fa0ee487d Run intl:extract 2025-07-11 16:22:20 -05:00
Josiah Glosson
62e2e5ea6f Open changelog with tauri-plugin-opener 2025-07-11 09:30:05 -05:00
Calum
69a461dffc feat: add error handling 2025-07-11 14:06:44 +01:00
Calum
35baa1af5e feat: polish update available modal 2025-07-10 20:00:39 +01:00
Calum
59ab09a275 feat: create AppearingProgressBar component 2025-07-10 17:50:24 +01:00
Josiah Glosson
286ab6d4a0 Make CI also lint updater code 2025-07-10 08:35:41 -05:00
Josiah Glosson
f9a4042f13 Fix lint 2025-07-10 08:28:15 -05:00
Josiah Glosson
3b74e021b5 Fix PendingUpdateData being seen as a unit struct 2025-07-09 23:07:07 -05:00
Josiah Glosson
8922c7ab03 Restore 5 minute update check instead of 30 seconds 2025-07-09 20:42:23 -05:00
Josiah Glosson
5495b01bc5 Turn download progress into an error bar on failure 2025-07-09 20:41:11 -05:00
Josiah Glosson
9b103e063a Implement updating at next exit 2025-07-09 20:41:11 -05:00
Josiah Glosson
7b73aa2908 Implement the Update Now button 2025-07-09 20:41:11 -05:00
Josiah Glosson
ae75292fd0 Implement skipping the update 2025-07-09 20:41:11 -05:00
Josiah Glosson
9a43d49b3b Fix lint 2025-07-09 20:41:11 -05:00
Josiah Glosson
3d4d0afa59 Slight UI tweaks 2025-07-09 20:41:11 -05:00
Josiah Glosson
9aab6c3e08 Fix update not being rechecked if the update modal was directly dismissed 2025-07-09 20:41:10 -05:00
Josiah Glosson
52d6bf3907 Show update size in modal 2025-07-09 20:41:10 -05:00
Josiah Glosson
523800ea39 Fix lint 2025-07-09 20:41:10 -05:00
Josiah Glosson
35aea3cab2 Add in the buttons and body 2025-07-09 20:41:10 -05:00
Josiah Glosson
36cb3f1686 Fix formatjs on Windows and run formatjs 2025-07-09 20:41:10 -05:00
Josiah Glosson
e59dd086fc Move update checking entirely into JS and open a modal if an update is available 2025-07-09 20:41:10 -05:00
Josiah Glosson
607c42cf01 Make theseus capable of logging messages from the log crate 2025-07-09 20:41:10 -05:00
73 changed files with 1882 additions and 2507 deletions

View File

@@ -2,5 +2,8 @@
[target.'cfg(windows)']
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
[build]
rustflags = ["--cfg", "tokio_unstable"]

View File

@@ -75,7 +75,7 @@ jobs:
rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
chmod: 0755
- name: ⚙️ Set application version
- name: ⚙️ Set application version and environment
shell: bash
run: |
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
@@ -84,6 +84,8 @@ jobs:
dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version'
cp packages/app-lib/.env.prod packages/app-lib/.env
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8

View File

@@ -74,10 +74,14 @@ jobs:
cp .env.local .env
sqlx database setup
- name: ⚙️ Set app environment
working-directory: packages/app-lib
run: cp .env.staging .env
- name: 🔍 Lint and test
run: pnpm run ci
- name: 🔍 Verify intl:extract has been run
run: |
pnpm intl:extract
git diff --exit-code */*/src/locales/en-US/index.json
git diff --exit-code --color */*/src/locales/en-US/index.json

1
Cargo.lock generated
View File

@@ -8930,6 +8930,7 @@ dependencies = [
"data-url",
"dirs",
"discord-rich-presence",
"dotenvy",
"dunce",
"either",
"encoding_rs",

View File

@@ -9,7 +9,7 @@
"tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "vue-tsc --noEmit"
},
"dependencies": {

View File

@@ -1,5 +1,14 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue'
import {
computed,
onMounted,
onUnmounted,
ref,
watch,
useTemplateRef,
provide,
nextTick,
} from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import {
ArrowBigUpDashIcon,
@@ -33,7 +42,7 @@ import { useLoading, useTheming } from '@/store/state'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { get } from '@/helpers/settings.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
@@ -42,7 +51,7 @@ import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
import { handleError, useNotifications } from '@/store/notifications.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { type } from '@tauri-apps/plugin-os'
import { getOS, isDev, restartApp } from '@/helpers/utils.js'
import { areUpdatesEnabled, getOS, isDev } from '@/helpers/utils.js'
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { getVersion } from '@tauri-apps/api/app'
@@ -59,7 +68,6 @@ import { get_opening_command, initialize_state } from '@/helpers/state'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { renderString } from '@modrinth/utils'
import { useFetch } from '@/helpers/fetch.js'
import { check } from '@tauri-apps/plugin-updater'
import NavButton from '@/components/ui/NavButton.vue'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js'
@@ -69,8 +77,11 @@ import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import UpdateModal from '@/components/ui/UpdateModal.vue'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { createTooltip, destroyTooltip } from 'floating-vue'
const themeStore = useTheming()
@@ -109,6 +120,18 @@ onUnmounted(() => {
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
})
const { formatMessage } = useVIntl()
const messages = defineMessages({
updateInstalledToastTitle: {
id: 'app.update.complete-toast.title',
defaultMessage: 'Version {version} was successfully installed!',
},
updateInstalledToastText: {
id: 'app.update.complete-toast.text',
defaultMessage: 'Click here to view the changelog.',
},
})
async function setupApp() {
stateInitialized.value = true
const {
@@ -122,7 +145,8 @@ async function setupApp() {
toggle_sidebar,
developer_mode,
feature_flags,
} = await get()
pending_update_toast_for_version,
} = await getSettings()
if (default_page === 'Library') {
await router.push('/library')
@@ -209,7 +233,6 @@ async function setupApp() {
})
get_opening_command().then(handleCommand)
checkUpdates()
fetchCredentials()
try {
@@ -219,6 +242,22 @@ async function setupApp() {
} catch (error) {
console.warn('Failed to generate skin previews in app setup.', error)
}
if (pending_update_toast_for_version !== null) {
const settings = await getSettings()
settings.pending_update_toast_for_version = null
await setSettings(settings)
const version = await getVersion()
if (pending_update_toast_for_version === version) {
notifications.addNotification({
type: 'success',
title: formatMessage(messages.updateInstalledToastTitle, { version }),
text: formatMessage(messages.updateInstalledToastText),
clickAction: () => openUrl('https://modrinth.com/news/changelog?filter=app'),
})
}
}
}
const stateFailed = ref(false)
@@ -346,19 +385,95 @@ async function handleCommand(e) {
}
}
const updateAvailable = ref(false)
const availableUpdate = ref(null)
const updateSkipped = ref(false)
const enqueuedUpdate = ref(null)
const updateModal = useTemplateRef('updateModal')
async function checkUpdates() {
const update = await check()
updateAvailable.value = !!update
if (!(await areUpdatesEnabled())) {
console.log('Skipping update check as updates are disabled in this build')
return
}
async function performCheck() {
if (updateModal.value.isOpen) {
console.log('Skipping update check because the update modal is already open')
return
}
const update = await invoke('plugin:updater|check')
if (!update) {
return
}
console.log(`Update ${update.version} is available.`)
if (update.version === availableUpdate.value?.version) {
console.log(
'Skipping update modal because the new version is the same as the dismissed update',
)
return
}
availableUpdate.value = update
const settings = await getSettings()
if (settings.skipped_update === update.version) {
updateSkipped.value = true
console.log('Skipping update modal because the user chose to skip this update')
return
}
updateSkipped.value = false
updateModal.value.show(update)
}
await performCheck()
setTimeout(
() => {
checkUpdates()
},
5 * 1000 * 60,
5 * 60 * 1000,
)
}
async function skipUpdate(version) {
enqueuedUpdate.value = null
updateSkipped.value = true
const settings = await getSettings()
settings.skipped_update = version
await setSettings(settings)
}
async function updateEnqueuedForLater(version) {
enqueuedUpdate.value = version
}
async function forceOpenUpdateModal() {
if (updateSkipped.value) {
updateSkipped.value = false
const settings = await getSettings()
settings.skipped_update = null
await setSettings(settings)
}
updateModal.value.show(availableUpdate.value)
}
const updateButton = useTemplateRef('updateButton')
async function showUpdateButtonTooltip() {
await nextTick()
const tooltip = createTooltip(updateButton.value.$el, {
placement: 'right',
content: 'Click here to view the update again.',
})
tooltip.show()
setTimeout(() => {
tooltip.hide()
destroyTooltip(updateButton.value.$el)
}, 3500)
}
function handleClick(e) {
let target = e.target
while (target != null) {
@@ -399,6 +514,14 @@ function handleAuxClick(e) {
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<div id="teleports"></div>
<div v-if="stateInitialized" class="app-grid-layout experimental-styles-within relative">
<Suspense @resolve="checkUpdates">
<UpdateModal
ref="updateModal"
@update-skipped="skipUpdate"
@update-enqueued-for-later="updateEnqueuedForLater"
@modal-hidden="showUpdateButtonTooltip"
/>
</Suspense>
<Suspense>
<AppSettingsModal ref="settingsModal" />
</Suspense>
@@ -449,8 +572,18 @@ function handleAuxClick(e) {
<PlusIcon />
</NavButton>
<div class="flex flex-grow"></div>
<NavButton v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()">
<DownloadIcon />
<NavButton
v-if="!!availableUpdate"
ref="updateButton"
v-tooltip.right="
enqueuedUpdate === availableUpdate?.version
? 'Update installation queued for next restart'
: 'Update available'
"
:to="forceOpenUpdateModal"
>
<DownloadIcon v-if="updateSkipped || enqueuedUpdate === availableUpdate?.version" />
<DownloadIcon v-else class="text-brand-green" />
</NavButton>
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
<SettingsIcon />

View File

@@ -1,6 +1,12 @@
<template>
<div class="progress-bar">
<div class="progress-bar__fill" :style="{ width: `${progress}%` }"></div>
<div
class="progress-bar__fill"
:style="{
width: `${progress}%`,
'background-color': error ? 'var(--color-red)' : 'var(--color-brand)',
}"
></div>
</div>
</template>
@@ -13,6 +19,10 @@ defineProps({
return value >= 0 && value <= 100
},
},
error: {
type: Boolean,
default: false,
},
})
</script>
@@ -27,7 +37,6 @@ defineProps({
.progress-bar__fill {
height: 100%;
background-color: var(--color-brand);
transition: width 0.3s;
}
</style>

View File

@@ -0,0 +1,303 @@
<template>
<ModalWrapper ref="modal" hide-header :closable="false" :on-hide="onHide">
<div class="flex flex-col gap-4">
<div class="w-[500px]">
<div class="font-extrabold text-contrast text-xl">
{{ formatMessage(messages.header) }} Modrinth App v{{ update!.version }}
</div>
<template v-if="!downloadInProgress && !downloadError">
<div class="mb-1 leading-tight">{{ formatMessage(messages.bodyVersion) }}</div>
<div class="text-sm text-secondary mb-2">
{{ formatMessage(messages.downloadSize, { size: formatBytes(updateSize) }) }}
</div>
</template>
<AppearingProgressBar
v-if="!downloadError"
:max-value="shouldShowProgress ? updateSize || 0 : 0"
:current-value="shouldShowProgress ? downloadedBytes : 0"
color="green"
class="w-full mb-4 mt-2"
/>
<div v-if="downloadError" class="leading-tight">
<div class="text-red font-medium mb-4">
{{ formatMessage(messages.downloadError) }}
</div>
<div class="flex flex-wrap gap-2">
<ButtonStyled color="brand">
<button @click="installUpdateNow">
<DownloadIcon />
{{ formatMessage(messages.tryAgain) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyError">
<ClipboardCopyIcon />
{{
copiedError
? formatMessage(messages.copiedError)
: formatMessage(messages.copyError)
}}
</button>
</ButtonStyled>
<ButtonStyled>
<a href="https://support.modrinth.com"><ChatIcon /> Get support</a>
</ButtonStyled>
</div>
</div>
<div v-if="!downloadError" class="flex flex-wrap gap-2 w-full">
<JoinedButtons
:actions="installActions"
:disabled="updatingImmediately || downloadInProgress"
color="brand"
/>
<div>
<ButtonStyled>
<button @click="() => openUrl('https://modrinth.com/news/changelog?filter=app')">
<ExternalIcon /> {{ formatMessage(messages.changelog) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { useTemplateRef, ref, computed } from 'vue'
import { AppearingProgressBar, ButtonStyled, JoinedButtons } from '@modrinth/ui'
import type { JoinedButtonAction } from '@modrinth/ui'
import { ExternalIcon, DownloadIcon, RedoIcon, ClipboardCopyIcon, XIcon } from '@modrinth/assets'
import { enqueueUpdateForInstallation, getUpdateSize } from '@/helpers/utils'
import { formatBytes } from '@modrinth/utils'
import { handleError } from '@/store/notifications'
import { loading_listener } from '@/helpers/events'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ChatIcon } from '@/assets/icons'
const emit = defineEmits<{
(e: 'updateEnqueuedForLater', version: string | null): Promise<void>
(e: 'modalHidden'): void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.update.modal-header',
defaultMessage: 'Update available - ',
},
copiedError: {
id: 'app.update.copied-error',
defaultMessage: 'Copied to clipboard!',
},
bodyVersion: {
id: 'app.update.modal-body-version',
defaultMessage:
'We recommend updating as soon as possible so you can enjoy the latest features, fixes, and improvements.',
},
downloadSize: {
id: 'app.update.download-size',
defaultMessage: 'The update is {size}.',
},
changelog: {
id: 'app.update.changelog',
defaultMessage: 'View changelog',
},
restartNow: {
id: 'app.update.restart',
defaultMessage: 'Update now',
},
later: {
id: 'app.update.later',
defaultMessage: 'Update on exit',
},
downloadError: {
id: 'app.update.download-error',
defaultMessage:
'An error occurred while downloading the update. Please try again later. Contact support if the issue persists.',
},
copyError: {
id: 'app.update.copy-error',
defaultMessage: 'Copy error',
},
tryAgain: {
id: 'app.update.try-again',
defaultMessage: 'Try again',
},
hide: {
id: 'app.update.hide',
defaultMessage: 'Hide update reminder',
},
})
type UpdateData = {
rid: number
currentVersion: string
version: string
date?: string
body?: string
rawJson: Record<string, unknown>
}
const update = ref<UpdateData>()
const updateSize = ref<number>()
const updatingImmediately = ref(false)
const downloadInProgress = ref(false)
const downloadProgress = ref(0)
const copiedError = ref(false)
const downloadError = ref<Error | null>(null)
const enqueuedUpdate = ref<string | null>(null)
const installActions = computed<JoinedButtonAction[]>(() => [
{
id: 'install-now',
label: formatMessage(messages.restartNow),
icon: DownloadIcon,
action: installUpdateNow,
color: 'green',
},
{
id: 'install-later',
label: formatMessage(messages.later),
icon: RedoIcon,
action: updateAtNextExit,
},
{
id: 'hide',
label: formatMessage(messages.hide),
action: () => {
hide()
emit('modalHidden')
},
icon: XIcon,
},
])
const downloadedBytes = computed(() => {
return updateSize.value ? Math.round((downloadProgress.value / 100) * updateSize.value) : 0
})
const shouldShowProgress = computed(() => {
return downloadInProgress.value || updatingImmediately.value
})
const modal = useTemplateRef('modal')
const isOpen = ref(false)
async function show(newUpdate: UpdateData) {
const oldVersion = update.value?.version
update.value = newUpdate
updateSize.value = await getUpdateSize(newUpdate.rid).catch(handleError)
if (oldVersion !== update.value?.version) {
downloadProgress.value = 0
}
modal.value!.show(new MouseEvent('click'))
isOpen.value = true
}
function onHide() {
isOpen.value = false
}
function hide() {
modal.value!.hide()
}
defineExpose({ show, hide, isOpen })
async function copyError() {
if (downloadError.value) {
copiedError.value = true
const errorData = {
message: downloadError.value.message,
stack: downloadError.value.stack,
name: downloadError.value.name,
timestamp: new Date().toISOString(),
updateVersion: update.value?.version,
}
setTimeout(() => {
copiedError.value = false
}, 3000)
try {
await navigator.clipboard.writeText(JSON.stringify(errorData, null, 2))
} catch (e) {
console.error('Failed to copy error to clipboard:', e)
}
}
}
// TODO: Migrate to common events.ts helper when events/listeners are refactored
interface LoadingListenerEvent {
event: {
type: 'launcher_update'
version: string
}
fraction?: number
}
loading_listener((event: LoadingListenerEvent) => {
if (event.event.type === 'launcher_update') {
if (event.event.version === update.value!.version) {
downloadProgress.value = (event.fraction ?? 1.0) * 100
}
}
})
function installUpdateNow() {
updatingImmediately.value = true
if (enqueuedUpdate.value !== update.value!.version) {
downloadUpdate()
} else if (!downloadInProgress.value) {
// Update already downloaded. Simply close the app
getCurrentWindow().close()
}
}
function updateAtNextExit() {
enqueuedUpdate.value = update.value!.version
emit('updateEnqueuedForLater', update.value!.version)
downloadUpdate()
hide()
}
async function downloadUpdate() {
downloadError.value = null
downloadProgress.value = 0
const versionToDownload = update.value!.version
downloadInProgress.value = true
try {
await enqueueUpdateForInstallation(update.value!.rid)
} catch (e) {
downloadInProgress.value = false
downloadError.value = e instanceof Error ? e : new Error(String(e))
handleError(e)
enqueuedUpdate.value = null
updatingImmediately.value = false
await emit('updateEnqueuedForLater', null)
return
}
downloadInProgress.value = false
if (updatingImmediately.value && update.value!.version === versionToDownload) {
await getCurrentWindow().close()
}
}
</script>
<style scoped lang="scss"></style>

View File

@@ -11,6 +11,10 @@ const props = defineProps({
type: String,
default: null,
},
hideHeader: {
type: Boolean,
default: false,
},
closable: {
type: Boolean,
default: true,
@@ -48,7 +52,14 @@ function onModalHide() {
</script>
<template>
<Modal ref="modal" :header="header" :noblur="!themeStore.advancedRendering" @hide="onModalHide">
<Modal
ref="modal"
:header="header"
:noblur="!themeStore.advancedRendering"
:closable="closable"
:hide-header="hideHeader"
@hide="onModalHide"
>
<template #title>
<slot name="title" />
</template>

View File

@@ -62,6 +62,9 @@ export type AppSettings = {
developer_mode: boolean
feature_flags: Record<FeatureFlag, boolean>
skipped_update: string | null
pending_update_toast_for_version: string | null
}
// Get full settings object

View File

@@ -5,6 +5,22 @@ export async function isDev() {
return await invoke('is_dev')
}
export async function areUpdatesEnabled() {
return await invoke('are_updates_enabled')
}
export async function getUpdateSize(updateRid) {
return await invoke('get_update_size', { rid: updateRid })
}
export async function enqueueUpdateForInstallation(updateRid) {
return await invoke('enqueue_update_for_installation', { rid: updateRid })
}
export async function removeEnqueuedUpdate() {
return await invoke('remove_enqueued_update')
}
// One of 'Windows', 'Linux', 'MacOS'
export async function getOS() {
return await invoke('plugin:utils|get_os')
@@ -37,13 +53,6 @@ export async function restartApp() {
return await invoke('restart_app')
}
/**
* @deprecated This method is no longer needed, and just returns its parameter
*/
export function sanitizePotentialFileUrl(url) {
return url
}
export const releaseColor = (releaseType) => {
switch (releaseType) {
case 'release':

View File

@@ -20,6 +20,45 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"app.update.changelog": {
"message": "View changelog"
},
"app.update.complete-toast.text": {
"message": "Click here to view the changelog."
},
"app.update.complete-toast.title": {
"message": "Version {version} was successfully installed!"
},
"app.update.copied-error": {
"message": "Copied to clipboard!"
},
"app.update.copy-error": {
"message": "Copy error"
},
"app.update.download-error": {
"message": "An error occurred while downloading the update. Please try again later. Contact support if the issue persists."
},
"app.update.download-size": {
"message": "The update is {size}."
},
"app.update.hide": {
"message": "Hide update reminder"
},
"app.update.later": {
"message": "Update on exit"
},
"app.update.modal-body-version": {
"message": "We recommend updating as soon as possible so you can enjoy the latest features, fixes, and improvements."
},
"app.update.modal-header": {
"message": "Update available - "
},
"app.update.restart": {
"message": "Update now"
},
"app.update.try-again": {
"message": "Try again"
},
"instance.add-server.add-and-play": {
"message": "Add and play"
},

View File

@@ -5,8 +5,8 @@
"build": "tauri build",
"dev": "tauri dev",
"test": "cargo nextest run --all-targets --no-fail-fast",
"lint": "cargo fmt --check && cargo clippy --all-targets",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt"
"lint": "cargo fmt --check && cargo clippy --all-targets && cargo clippy --all-targets --features updater",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo clippy --all-targets --features updater --fix --allow-dirty && cargo fmt"
},
"devDependencies": {
"@tauri-apps/cli": "2.5.0"

View File

@@ -45,8 +45,12 @@ pub enum TheseusSerializableError {
Tauri(#[from] tauri::Error),
#[cfg(feature = "updater")]
#[error("Tauri updater error: {0}")]
TauriUpdater(#[from] tauri_plugin_updater::Error),
#[error("Updater error: {0}")]
Updater(#[from] tauri_plugin_updater::Error),
#[cfg(feature = "updater")]
#[error("HTTP error: {0}")]
Http(#[from] tauri_plugin_http::reqwest::Error),
}
// Generic implementation of From<T> for ErrorTypeA
@@ -104,5 +108,6 @@ impl_serialize! {
impl_serialize! {
IO,
Tauri,
TauriUpdater,
Updater,
Http,
}

View File

@@ -14,6 +14,11 @@ mod error;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(feature = "updater")]
mod updater_impl;
#[cfg(not(feature = "updater"))]
mod updater_impl_noop;
// Should be called in launcher initialization
#[tracing::instrument(skip_all)]
#[tauri::command]
@@ -21,75 +26,9 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
tracing::info!("Initializing app event state...");
theseus::EventState::init(app.clone()).await?;
#[cfg(feature = "updater")]
'updater: {
if env::var("MODRINTH_EXTERNAL_UPDATE_PROVIDER").is_ok() {
State::init().await?;
break 'updater;
}
tracing::info!("Initializing app state...");
State::init().await?;
use tauri_plugin_updater::UpdaterExt;
let updater = app.updater_builder().build()?;
let update_fut = updater.check();
tracing::info!("Initializing app state...");
State::init().await?;
let check_bar = theseus::init_loading(
theseus::LoadingBarType::CheckingForUpdates,
1.0,
"Checking for updates...",
)
.await?;
tracing::info!("Checking for updates...");
let update = update_fut.await;
drop(check_bar);
if let Some(update) = update.ok().flatten() {
tracing::info!("Update found: {:?}", update.download_url);
let loader_bar_id = theseus::init_loading(
theseus::LoadingBarType::LauncherUpdate {
version: update.version.clone(),
current_version: update.current_version.clone(),
},
1.0,
"Updating Modrinth App...",
)
.await?;
// 100 MiB
const DEFAULT_CONTENT_LENGTH: u64 = 1024 * 1024 * 100;
update
.download_and_install(
|chunk_length, content_length| {
let _ = theseus::emit_loading(
&loader_bar_id,
(chunk_length as f64)
/ (content_length
.unwrap_or(DEFAULT_CONTENT_LENGTH)
as f64),
None,
);
},
|| {},
)
.await?;
app.restart();
}
}
#[cfg(not(feature = "updater"))]
{
State::init().await?;
}
tracing::info!("Finished checking for updates!");
let state = State::get().await?;
app.asset_protocol_scope()
.allow_directory(state.directories.caches_dir(), true)?;
@@ -125,6 +64,17 @@ fn is_dev() -> bool {
cfg!(debug_assertions)
}
#[tauri::command]
fn are_updates_enabled() -> bool {
cfg!(feature = "updater")
}
#[cfg(feature = "updater")]
pub use updater_impl::*;
#[cfg(not(feature = "updater"))]
pub use updater_impl_noop::*;
// Toggles decorations
#[tauri::command]
async fn toggle_decorations(b: bool, window: tauri::Window) -> api::Result<()> {
@@ -166,7 +116,17 @@ fn main() {
#[cfg(feature = "updater")]
{
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
use tauri_plugin_http::reqwest::header::{HeaderValue, USER_AGENT};
use theseus::LAUNCHER_USER_AGENT;
builder = builder.plugin(
tauri_plugin_updater::Builder::new()
.header(
USER_AGENT,
HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(),
)
.unwrap()
.build(),
);
}
builder = builder
@@ -261,9 +221,14 @@ fn main() {
.plugin(api::ads::init())
.plugin(api::friends::init())
.plugin(api::worlds::init())
.manage(PendingUpdateData::default())
.invoke_handler(tauri::generate_handler![
initialize_state,
is_dev,
are_updates_enabled,
get_update_size,
enqueue_update_for_installation,
remove_enqueued_update,
toggle_decorations,
show_window,
restart_app,
@@ -275,8 +240,41 @@ fn main() {
match app {
Ok(app) => {
app.run(|app, event| {
#[cfg(not(target_os = "macos"))]
#[cfg(not(any(feature = "updater", target_os = "macos")))]
drop((app, event));
#[cfg(feature = "updater")]
if matches!(event, tauri::RunEvent::Exit) {
let update_data = app.state::<PendingUpdateData>().inner();
if let Some((update, data)) = &*update_data.0.lock().unwrap() {
fn set_changelog_toast(version: Option<String>) {
let toast_result: theseus::Result<()> = tauri::async_runtime::block_on(async move {
let mut settings = settings::get().await?;
settings.pending_update_toast_for_version = version;
settings::set(settings).await?;
Ok(())
});
if let Err(e) = toast_result {
tracing::warn!("Failed to set pending_update_toast: {e}")
}
}
set_changelog_toast(Some(update.version.clone()));
if let Err(e) = update.install(data) {
tracing::error!("Error while updating: {e}");
set_changelog_toast(None);
DialogBuilder::message()
.set_level(MessageLevel::Error)
.set_title("Update error")
.set_text(format!("Failed to install update due to an error:\n{e}"))
.alert()
.show()
.unwrap();
}
}
}
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Opened { urls } = event {
tracing::info!("Handling webview open {urls:?}");
@@ -304,6 +302,8 @@ fn main() {
});
}
Err(e) => {
tracing::error!("Error while running tauri application: {:?}", e);
#[cfg(target_os = "windows")]
{
// tauri doesn't expose runtime errors, so matching a string representation seems like the only solution
@@ -332,7 +332,6 @@ fn main() {
.show()
.unwrap();
tracing::error!("Error while running tauri application: {:?}", e);
panic!("{1}: {:?}", e, "error while running tauri application")
}
}

View File

@@ -0,0 +1,121 @@
use crate::api::Result;
use std::sync::{Arc, Mutex};
use tauri::http::HeaderValue;
use tauri::http::header::ACCEPT;
use tauri::{Manager, ResourceId, Runtime, Webview};
use tauri_plugin_http::reqwest;
use tauri_plugin_http::reqwest::ClientBuilder;
use tauri_plugin_updater::Error;
use tauri_plugin_updater::Update;
use theseus::{
LAUNCHER_USER_AGENT, LoadingBarType, emit_loading, init_loading,
};
use tokio::time::Instant;
#[derive(Default)]
pub struct PendingUpdateData(pub Mutex<Option<(Arc<Update>, Vec<u8>)>>);
// Reimplementation of Update::download mostly, minus the actual download part
#[tauri::command]
pub async fn get_update_size<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> Result<Option<u64>> {
let update = webview.resources_table().get::<Update>(rid)?;
let mut headers = update.headers.clone();
if !headers.contains_key(ACCEPT) {
headers.insert(
ACCEPT,
HeaderValue::from_static("application/octet-stream"),
);
}
let mut request = ClientBuilder::new().user_agent(LAUNCHER_USER_AGENT);
if let Some(timeout) = update.timeout {
request = request.timeout(timeout);
}
if let Some(ref proxy) = update.proxy {
let proxy = reqwest::Proxy::all(proxy.as_str())?;
request = request.proxy(proxy);
}
let response = request
.build()?
.head(update.download_url.clone())
.headers(headers)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::Network(format!(
"Download request failed with status: {}",
response.status()
))
.into());
}
let content_length = response
.headers()
.get("Content-Length")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok());
Ok(content_length)
}
#[tauri::command]
pub async fn enqueue_update_for_installation<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> Result<()> {
let pending_data = webview.state::<PendingUpdateData>().inner();
let update = webview.resources_table().get::<Update>(rid)?;
let progress = init_loading(
LoadingBarType::LauncherUpdate {
version: update.version.clone(),
current_version: update.current_version.clone(),
},
1.0,
"Downloading update...",
)
.await?;
let download_start = Instant::now();
let update_data = update
.download(
|chunk_size, total_size| {
let Some(total_size) = total_size else {
return;
};
if let Err(e) = emit_loading(
&progress,
chunk_size as f64 / total_size as f64,
None,
) {
tracing::error!(
"Failed to update download progress bar: {e}"
);
}
},
|| {},
)
.await?;
let download_duration = download_start.elapsed();
tracing::info!("Downloaded update in {download_duration:?}");
pending_data
.0
.lock()
.unwrap()
.replace((update, update_data));
Ok(())
}
#[tauri::command]
pub fn remove_enqueued_update<R: Runtime>(webview: Webview<R>) {
let pending_data = webview.state::<PendingUpdateData>().inner();
pending_data.0.lock().unwrap().take();
}

View File

@@ -0,0 +1,26 @@
use crate::api::Result;
use theseus::ErrorKind;
#[derive(Default)]
pub struct PendingUpdateData(());
#[tauri::command]
pub fn get_update_size() -> Result<()> {
updates_are_disabled()
}
#[tauri::command]
pub fn enqueue_update_for_installation() -> Result<()> {
updates_are_disabled()
}
fn updates_are_disabled() -> Result<()> {
let error: theseus::Error = ErrorKind::OtherError(
"Updates are disabled in this build.".to_string(),
)
.into();
Err(error.into())
}
#[tauri::command]
pub fn remove_enqueued_update() {}

View File

@@ -34,7 +34,7 @@ const enabledLocales: string[] = [];
/**
* Overrides for the categories of the certain locales.
*/
const localesCategoriesOverrides: Partial = {
const localesCategoriesOverrides: Partial<Record<string, "fun" | "experimental">> = {
"en-x-pirate": "fun",
"en-x-updown": "fun",
"en-x-lolcat": "fun",
@@ -260,28 +260,21 @@ export default defineNuxtConfig({
const omorphiaLocales: string[] = [];
const omorphiaLocaleSets = new Map<string, { files: { from: string }[] }>();
const externalLocales = [
"node_modules/@modrinth/ui/src/locales/en-US",
"node_modules/@modrinth/moderation/locales/en-US",
];
for await (const localeDir of globIterate("node_modules/@modrinth/ui/src/locales/*", {
posix: true,
})) {
const tag = basename(localeDir);
omorphiaLocales.push(tag);
for (const localePath of externalLocales) {
for await (const localeDir of globIterate(localePath, {
posix: true,
})) {
const tag = basename(localeDir);
omorphiaLocales.push(tag);
const localeFiles: { from: string; format?: string }[] = [];
const localeFiles: { from: string; format?: string }[] = [];
omorphiaLocaleSets.set(tag, { files: localeFiles });
omorphiaLocaleSets.set(tag, { files: localeFiles });
for await (const localeFile of globIterate(`${localeDir}/*`, { posix: true })) {
localeFiles.push({
from: pathToFileURL(localeFile).toString(),
format: "default",
});
}
for await (const localeFile of globIterate(`${localeDir}/*`, { posix: true })) {
localeFiles.push({
from: pathToFileURL(localeFile).toString(),
format: "default",
});
}
}
@@ -308,7 +301,7 @@ export default defineNuxtConfig({
format: "crowdin",
});
} else if (fileName === "meta.json") {
const meta: Record = await fs
const meta: Record<string, { message: string }> = await fs
.readFile(localeFile, "utf8")
.then((date) => JSON.parse(date));
const localeMeta = (locale.meta ??= {});

View File

@@ -10,7 +10,7 @@
"postinstall": "nuxi prepare",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "nuxi build"
},
"devDependencies": {

View File

@@ -1,442 +1,510 @@
<template>
<div v-if="showInvitation" class="universal-card information invited my-4">
<h2>{{ getFormattedMessage(messages.invitationTitle) }}</h2>
<p v-if="currentMember?.project_role">
{{ formatMessage(messages.invitationWithRole, { role: currentMember.project_role }) }}
<div v-if="showInvitation" class="universal-card information invited">
<h2>Invitation to join project</h2>
<p>
You've been invited be a member of this project with the role of '{{ currentMember.role }}'.
</p>
<p v-else>{{ getFormattedMessage(messages.invitationNoRole) }}</p>
<div class="input-group">
<ButtonStyled color="brand">
<button class="brand-button" @click="acceptInvite()">
<CheckIcon />
{{ getFormattedMessage(messages.accept) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="declineInvite">
<XIcon />
{{ getFormattedMessage(messages.decline) }}
</button>
</ButtonStyled>
<button class="iconified-button brand-button" @click="acceptInvite()">
<CheckIcon />
Accept
</button>
<button class="iconified-button danger-button" @click="declineInvite()">
<XIcon />
Decline
</button>
</div>
</div>
<div
v-if="
currentMember &&
visibleNags.length > 0 &&
nags.filter((x) => x.condition).length > 0 &&
(project.status === 'draft' || tags.rejectedStatuses.includes(project.status))
"
class="universal-card my-4"
class="author-actions universal-card mb-4"
>
<div class="flex max-w-full flex-wrap items-center gap-x-6 gap-y-4">
<div class="flex flex-auto flex-wrap items-center gap-x-6 gap-y-4">
<h2 class="my-0 mr-auto">{{ getFormattedMessage(messages.publishingChecklist) }}</h2>
<div class="header__row">
<div class="header__title">
<h2>Publishing checklist</h2>
<div class="checklist">
<span class="checklist__title">Progress:</span>
<div class="checklist__items">
<div
v-for="nag in nags"
:key="`checklist-${nag.id}`"
v-tooltip="nag.title"
:aria-label="nag.title"
:class="'circle ' + (!nag.condition ? 'done' : '') + nag.status"
class="circle"
>
<CheckIcon v-if="!nag.condition" />
<AsteriskIcon v-else-if="nag.status === 'required'" />
<LightBulbIcon v-else-if="nag.status === 'suggestion'" />
<ScaleIcon v-else-if="nag.status === 'review'" />
</div>
</div>
</div>
</div>
<div class="input-group">
<ButtonStyled circular>
<button :class="!collapsed && '[&>svg]:rotate-180'" @click="toggleCollapsed()">
<DropdownIcon class="duration-250 transition-transform ease-in-out" />
</button>
</ButtonStyled>
<button
:class="{ 'not-collapsed': !collapsed }"
class="square-button"
@click="toggleCollapsed()"
>
<DropdownIcon />
</button>
</div>
</div>
<div v-if="!collapsed" class="grid-display width-16 mt-4">
<div v-for="nag in visibleNags" :key="nag.id" class="grid-display__item">
<span class="flex items-center gap-2 font-semibold">
<component
:is="nag.icon || getDefaultIcon(nag.status)"
v-tooltip="getStatusTooltip(nag.status)"
:class="[
'h-4 w-4',
nag.status === 'required' && 'text-red',
nag.status === 'warning' && 'text-orange',
nag.status === 'suggestion' && 'text-purple',
]"
:aria-label="getStatusTooltip(nag.status)"
<div v-if="!collapsed" class="grid-display width-16">
<div
v-for="nag in nags.filter((x) => x.condition && !x.hide)"
:key="nag.id"
class="grid-display__item"
>
<span class="label">
<AsteriskIcon
v-if="nag.status === 'required'"
v-tooltip="'Required'"
:class="nag.status"
aria-label="Required"
/>
{{ getFormattedMessage(nag.title) }}
</span>
{{ getNagDescription(nag) }}
<LightBulbIcon
v-else-if="nag.status === 'suggestion'"
v-tooltip="'Suggestion'"
:class="nag.status"
aria-label="Suggestion"
/>
<ScaleIcon
v-else-if="nag.status === 'review'"
v-tooltip="'Review'"
:class="nag.status"
aria-label="Review"
/>{{ nag.title }}</span
>
{{ nag.description }}
<NuxtLink
v-if="nag.link && shouldShowLink(nag)"
v-if="nag.link"
:class="{ invisible: nag.link.hide }"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/${
nag.link.path
}`"
class="goto-link"
>
{{ getFormattedMessage(nag.link.title) }}
{{ nag.link.title }}
<ChevronRightIcon aria-hidden="true" class="featured-header-chevron" />
</NuxtLink>
<ButtonStyled
v-if="nag.status === 'special-submit-action' && nag.id === 'submit-for-review'"
color="orange"
@click="submitForReview"
<button
v-else-if="nag.action"
:disabled="nag.action.disabled()"
class="btn btn-orange"
@click="nag.action.onClick"
>
<button
:disabled="!canSubmitForReview"
v-tooltip="
!canSubmitForReview ? getFormattedMessage(messages.submitChecklistTooltip) : undefined
"
>
<SendIcon />
{{ getFormattedMessage(messages.submitForReview) }}
</button>
</ButtonStyled>
<SendIcon />
{{ nag.action.title }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
<script setup>
import {
ChevronRightIcon,
CheckIcon,
XIcon,
AsteriskIcon,
LightBulbIcon,
TriangleAlertIcon,
DropdownIcon,
SendIcon,
ScaleIcon,
DropdownIcon,
} from "@modrinth/assets";
import { formatProjectType } from "@modrinth/utils";
import { acceptTeamInvite, removeTeamMember } from "~/helpers/teams.js";
import { nags } from "@modrinth/moderation";
import { ButtonStyled } from "@modrinth/ui";
import { useVIntl, defineMessages, type MessageDescriptor } from "@vintl/vintl";
import type { Nag, NagContext, NagStatus } from "@modrinth/moderation";
import type { Project, User, Version } from "@modrinth/utils";
import type { Component } from "vue";
interface Tags {
rejectedStatuses: string[];
}
const props = defineProps({
project: {
type: Object,
required: true,
},
versions: {
type: Array,
default() {
return [];
},
},
currentMember: {
type: Object,
default: null,
},
allMembers: {
type: Object,
default: null,
},
isSettings: {
type: Boolean,
default: false,
},
collapsed: {
type: Boolean,
default: false,
},
routeName: {
type: String,
default: "",
},
auth: {
type: Object,
required: true,
},
tags: {
type: Object,
required: true,
},
setProcessing: {
type: Function,
default() {
return () => {
addNotification({
group: "main",
title: "An error occurred",
text: "setProcessing function not found",
type: "error",
});
};
},
},
toggleCollapsed: {
type: Function,
default() {
return () => {
addNotification({
group: "main",
title: "An error occurred",
text: "toggleCollapsed function not found",
type: "error",
});
};
},
},
updateMembers: {
type: Function,
default() {
return () => {
addNotification({
group: "main",
title: "An error occurred",
text: "updateMembers function not found",
type: "error",
});
};
},
},
});
interface Auth {
user: {
id: string;
};
}
const featuredGalleryImage = computed(() => props.project.gallery.find((img) => img.featured));
interface Member {
accepted?: boolean;
project_role?: string;
user?: Partial<User>;
}
interface Props {
project: Project;
versions?: Version[];
currentMember?: Member | null;
allMembers?: Member[] | null;
isSettings?: boolean;
collapsed?: boolean;
routeName?: string;
auth: Auth;
tags: Tags;
setProcessing?: (processing: boolean) => void;
toggleCollapsed?: () => void;
updateMembers?: () => void | Promise<void>;
}
const messages = defineMessages({
invitationTitle: {
id: "project-member-header.invitation-title",
defaultMessage: "Invitation to join project",
const nags = computed(() => [
{
condition: props.versions.length < 1,
title: "Upload a version",
id: "upload-version",
description: "At least one version is required for a project to be submitted for review.",
status: "required",
link: {
path: "versions",
title: "Visit versions page",
hide: props.routeName === "type-id-versions",
},
},
invitationWithRole: {
id: "project-member-header.invitation-with-role",
defaultMessage: "You've been invited be a member of this project with the role of '{role}'.",
{
condition:
props.project.body === "" || props.project.body.startsWith("# Placeholder description"),
title: "Add a description",
id: "add-description",
description:
"A description that clearly describes the project's purpose and function is required.",
status: "required",
link: {
path: "settings/description",
title: "Visit description settings",
hide: props.routeName === "type-id-settings-description",
},
},
invitationNoRole: {
id: "project-member-header.invitation-no-role",
defaultMessage:
"You've been invited to join this project. Please accept or decline the invitation.",
{
condition: !props.project.icon_url,
title: "Add an icon",
id: "add-icon",
description:
"Your project should have a nice-looking icon to uniquely identify your project at a glance.",
status: "suggestion",
link: {
path: "settings",
title: "Visit general settings",
hide: props.routeName === "type-id-settings",
},
},
accept: {
id: "project-member-header.accept",
defaultMessage: "Accept",
{
condition: props.project.gallery.length === 0 || !featuredGalleryImage,
title: "Feature a gallery image",
id: "feature-gallery-image",
description: "Featured gallery images may be the first impression of many users.",
status: "suggestion",
link: {
path: "gallery",
title: "Visit gallery page",
hide: props.routeName === "type-id-gallery",
},
},
decline: {
id: "project-member-header.decline",
defaultMessage: "Decline",
{
hide: props.project.versions.length === 0,
condition: props.project.categories.length < 1,
title: "Select tags",
id: "select-tags",
description: "Select all tags that apply to your project.",
status: "suggestion",
link: {
path: "settings/tags",
title: "Visit tag settings",
hide: props.routeName === "type-id-settings-tags",
},
},
publishingChecklist: {
id: "project-member-header.publishing-checklist",
defaultMessage: "Publishing checklist",
{
condition: !(
props.project.issues_url ||
props.project.source_url ||
props.project.wiki_url ||
props.project.discord_url ||
props.project.donation_urls.length > 0
),
title: "Add external links",
id: "add-links",
description:
"Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite.",
status: "suggestion",
link: {
path: "settings/links",
title: "Visit links settings",
hide: props.routeName === "type-id-settings-links",
},
},
submitForReview: {
id: "project-member-header.submit-for-review",
defaultMessage: "Submit for review",
{
hide:
props.project.versions.length === 0 ||
props.project.project_type === "resourcepack" ||
props.project.project_type === "plugin" ||
props.project.project_type === "shader" ||
props.project.project_type === "datapack",
condition:
props.project.client_side === "unknown" ||
props.project.server_side === "unknown" ||
(props.project.client_side === "unsupported" && props.project.server_side === "unsupported"),
title: "Select supported environments",
id: "select-environments",
description: `Select if the ${formatProjectType(
props.project.project_type,
).toLowerCase()} functions on the client-side and/or server-side.`,
status: "required",
link: {
path: "settings",
title: "Visit general settings",
hide: props.routeName === "type-id-settings",
},
},
submitForReviewDesc: {
id: "project-member-header.submit-for-review-desc",
defaultMessage:
{
condition: props.project.license.id === "LicenseRef-Unknown",
title: "Select license",
id: "select-license",
description: `Select the license your ${formatProjectType(
props.project.project_type,
).toLowerCase()} is distributed under.`,
status: "required",
link: {
path: "settings/license",
title: "Visit license settings",
hide: props.routeName === "type-id-settings-license",
},
},
{
condition: props.project.status === "draft",
title: "Submit for review",
id: "submit-for-review",
description:
"Your project is only viewable by members of the project. It must be reviewed by moderators in order to be published.",
status: "review",
link: null,
action: {
onClick: submitForReview,
title: "Submit for review",
disabled: () => nags.value.filter((x) => x.condition && x.status === "required").length > 0,
},
},
resubmitForReview: {
id: "project-member-header.resubmit-for-review",
defaultMessage: "Resubmit for review",
{
hide: props.project.stats === "draft",
condition: props.tags.rejectedStatuses.includes(props.project.status),
title: "Resubmit for review",
id: "resubmit-for-review",
description: `Your project has been ${props.project.status} by
Modrinth's staff. In most cases, you can resubmit for review after
addressing the staff's message.`,
status: "review",
link: {
path: "moderation",
title: "Visit moderation page",
hide: props.routeName === "type-id-moderation",
},
},
resubmitForReviewDesc: {
id: "project-member-header.resubmit-for-review-desc",
defaultMessage:
"Your project has been {status} by Modrinth's staff. In most cases, you can resubmit for review after addressing the staff's message.",
},
visitModerationPage: {
id: "project-member-header.visit-moderation-page",
defaultMessage: "Visit moderation page",
},
submitChecklistTooltip: {
id: "project-member-header.submit-checklist-tooltip",
defaultMessage: "You must complete the required steps in the publishing checklist!",
},
successJoin: {
id: "project-member-header.success-join",
defaultMessage: "You have joined the project team",
},
errorJoin: {
id: "project-member-header.error-join",
defaultMessage: "Failed to accept team invitation",
},
successDecline: {
id: "project-member-header.success-decline",
defaultMessage: "You have declined the team invitation",
},
errorDecline: {
id: "project-member-header.error-decline",
defaultMessage: "Failed to decline team invitation",
},
success: {
id: "project-member-header.success",
defaultMessage: "Success",
},
error: {
id: "project-member-header.error",
defaultMessage: "Error",
},
required: {
id: "project-member-header.required",
defaultMessage: "Required",
},
warning: {
id: "project-member-header.warning",
defaultMessage: "Warning",
},
suggestion: {
id: "project-member-header.suggestion",
defaultMessage: "Suggestion",
},
});
]);
const { formatMessage } = useVIntl();
function getNagDescription(nag: Nag): string {
if (typeof nag.description === "function") {
return nag.description(nagContext.value);
}
return formatMessage(nag.description);
}
function getFormattedMessage(message: string | MessageDescriptor): string {
if (typeof message === "string") {
return message;
}
return formatMessage(message);
}
const props = withDefaults(defineProps<Props>(), {
versions: () => [],
currentMember: null,
allMembers: null,
isSettings: false,
collapsed: false,
routeName: "",
});
const emit = defineEmits<{
toggleCollapsed: [];
updateMembers: [];
setProcessing: [processing: boolean];
}>();
const nagContext = computed<NagContext>(() => ({
project: props.project,
versions: props.versions,
currentMember: props.currentMember as User,
currentRoute: props.routeName,
tags: props.tags,
submitProject: submitForReview,
}));
const canSubmitForReview = computed(() => {
return (
applicableNags.value.filter((nag) => nag.status === "required" && !isNagComplete(nag))
.length === 0
);
});
async function submitForReview() {
if (canSubmitForReview) {
await setProcessing(true);
}
}
const applicableNags = computed<Nag[]>(() => {
return nags.filter((nag) => {
return nag.shouldShow(nagContext.value);
});
});
function isNagComplete(nag: Nag): boolean {
const context = nagContext.value;
return !nag.shouldShow(context);
}
const visibleNags = computed<Nag[]>(() => {
const finalNags = applicableNags.value.filter((nag) => !isNagComplete(nag));
if (props.project.status === "draft") {
finalNags.push({
id: "submit-for-review",
title: messages.submitForReview,
description: () => formatMessage(messages.submitForReviewDesc),
status: "special-submit-action",
shouldShow: (ctx) => ctx.project.status === "draft",
});
}
if (props.tags.rejectedStatuses.includes(props.project.status)) {
finalNags.push({
id: "resubmit-for-review",
title: messages.resubmitForReview,
description: (ctx) =>
formatMessage(messages.resubmitForReviewDesc, { status: ctx.project.status }),
status: "special-submit-action",
shouldShow: (ctx) => ctx.tags.rejectedStatuses.includes(ctx.project.status),
link: {
path: "moderation",
title: messages.visitModerationPage,
shouldShow: () => props.routeName !== "type-id-moderation",
},
});
}
return finalNags;
});
function shouldShowLink(nag: Nag): boolean {
return nag.link?.shouldShow ? nag.link.shouldShow(nagContext.value) : false;
}
function getDefaultIcon(status: NagStatus): Component {
switch (status) {
case "required":
return AsteriskIcon;
case "warning":
return TriangleAlertIcon;
case "suggestion":
return LightBulbIcon;
case "special-submit-action":
return ScaleIcon;
default:
return AsteriskIcon;
}
}
function getStatusTooltip(status: NagStatus): string {
switch (status) {
case "required":
return formatMessage(messages.required);
case "warning":
return formatMessage(messages.warning);
case "suggestion":
return formatMessage(messages.suggestion);
default:
return formatMessage(messages.required);
}
}
const showInvitation = computed<boolean>(() => {
const showInvitation = computed(() => {
if (props.allMembers && props.auth) {
const member = props.allMembers.find((x) => x?.user?.id === props.auth.user.id);
return !!member && !member.accepted;
const member = props.allMembers.find((x) => x.user.id === props.auth.user.id);
return member && !member.accepted;
}
return false;
});
function toggleCollapsed(): void {
if (props.toggleCollapsed) {
props.toggleCollapsed();
} else {
emit("toggleCollapsed");
}
}
const acceptInvite = () => {
acceptTeamInvite(props.project.team);
props.updateMembers();
};
async function updateMembers(): Promise<void> {
if (props.updateMembers) {
await props.updateMembers();
} else {
emit("updateMembers");
}
}
const declineInvite = () => {
removeTeamMember(props.project.team, props.auth.user.id);
props.updateMembers();
};
function setProcessing(processing: boolean): void {
if (props.setProcessing) {
props.setProcessing(processing);
} else {
emit("setProcessing", processing);
const submitForReview = async () => {
if (
!props.acknowledgedMessage ||
nags.value.filter((x) => x.condition && x.status === "required").length === 0
) {
await props.setProcessing();
}
}
async function acceptInvite(): Promise<void> {
try {
setProcessing(true);
await acceptTeamInvite(props.project.team);
await updateMembers();
addNotification({
group: "main",
title: formatMessage(messages.success),
text: formatMessage(messages.successJoin),
type: "success",
});
} catch (error) {
addNotification({
group: "main",
title: formatMessage(messages.error),
text: formatMessage(messages.errorJoin),
type: "error",
});
} finally {
setProcessing(false);
}
}
async function declineInvite(): Promise<void> {
try {
setProcessing(true);
await removeTeamMember(props.project.team, props.auth.user.id);
await updateMembers();
addNotification({
group: "main",
title: formatMessage(messages.success),
text: formatMessage(messages.successDecline),
type: "success",
});
} catch (error) {
addNotification({
group: "main",
title: formatMessage(messages.error),
text: formatMessage(messages.errorDecline),
type: "error",
});
} finally {
setProcessing(false);
}
}
};
</script>
<style lang="scss" scoped>
.duration-250 {
transition-duration: 250ms;
.invited {
}
.author-actions {
margin-top: var(--spacing-card-md);
&:empty {
display: none;
}
.invisible {
visibility: hidden;
}
.header__row {
align-items: center;
column-gap: var(--spacing-card-lg);
row-gap: var(--spacing-card-md);
max-width: 100%;
.header__title {
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: var(--spacing-card-lg);
row-gap: var(--spacing-card-md);
flex-basis: min-content;
h2 {
margin: 0 auto 0 0;
}
}
button {
svg {
transition: transform 0.25s ease-in-out;
}
&.not-collapsed svg {
transform: rotate(180deg);
}
}
}
.grid-display__item .label {
display: flex;
gap: var(--spacing-card-xs);
align-items: center;
.required {
color: var(--color-red);
}
.suggestion {
color: var(--color-purple);
}
.review {
color: var(--color-orange);
}
}
.checklist {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-card-xs);
width: fit-content;
flex-wrap: wrap;
max-width: 100%;
.checklist__title {
font-weight: bold;
margin-right: var(--spacing-card-xs);
color: var(--color-text-dark);
}
.checklist__items {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-card-xs);
width: fit-content;
max-width: 100%;
}
.circle {
--circle-size: 2rem;
--background-color: var(--color-bg);
--content-color: var(--color-gray);
width: var(--circle-size);
height: var(--circle-size);
border-radius: 50%;
background-color: var(--background-color);
display: flex;
justify-content: center;
align-items: center;
svg {
color: var(--content-color);
width: calc(var(--circle-size) / 2);
height: calc(var(--circle-size) / 2);
}
&.required {
--content-color: var(--color-red);
}
&.suggestion {
--content-color: var(--color-purple);
}
&.review {
--content-color: var(--color-orange);
}
&.done {
--background-color: var(--color-green);
--content-color: var(--color-brand-inverted);
}
}
}
}
</style>

View File

@@ -1,36 +1,7 @@
<template>
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
<div class="flex flex-col gap-4 md:w-[600px]">
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="isLoading" class="w-full">
<div class="mb-2 flex justify-between text-sm">
<Transition name="phrase-fade" mode="out-in">
<span :key="currentPhrase" class="text-lg font-medium text-contrast">{{
currentPhrase
}}</span>
</Transition>
<div class="flex flex-col items-end">
<span class="text-secondary">{{ Math.round(uploadProgress) }}%</span>
<span class="text-xs text-secondary"
>{{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }}</span
>
</div>
</div>
<div class="h-2 w-full rounded-full bg-divider">
<div
class="h-2 animate-pulse rounded-full bg-brand transition-all duration-300 ease-out"
:style="{ width: `${uploadProgress}%` }"
></div>
</div>
</div>
</Transition>
<AppearingProgressBar :max-value="totalBytes" :current-value="uploadedBytes" />
<Transition
enter-active-class="transition-all duration-300 ease-out"
@@ -144,7 +115,7 @@
</template>
<script setup lang="ts">
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { BackupWarning, ButtonStyled, NewModal, AppearingProgressBar } from "@modrinth/ui";
import {
UploadIcon,
RightArrowIcon,
@@ -187,50 +158,9 @@ const hardReset = ref(false);
const isLoading = ref(false);
const loadingServerCheck = ref(false);
const mrpackFile = ref<File | null>(null);
const uploadProgress = ref(0);
const uploadedBytes = ref(0);
const totalBytes = ref(0);
const uploadPhrases = [
"Removing Herobrine...",
"Feeding parrots...",
"Teaching villagers new trades...",
"Convincing creepers to be friendly...",
"Polishing diamonds...",
"Training wolves to fetch...",
"Building pixel art...",
"Explaining redstone to beginners...",
"Collecting all the cats...",
"Negotiating with endermen...",
"Planting suspicious stew ingredients...",
"Calibrating TNT blast radius...",
"Teaching chickens to fly...",
"Sorting inventory alphabetically...",
"Convincing iron golems to smile...",
];
const currentPhrase = ref("Uploading...");
let phraseInterval: NodeJS.Timeout | null = null;
const usedPhrases = ref(new Set<number>());
const getNextPhrase = () => {
if (usedPhrases.value.size >= uploadPhrases.length) {
const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value);
usedPhrases.value.clear();
if (currentPhraseIndex !== -1) {
usedPhrases.value.add(currentPhraseIndex);
}
}
const availableIndices = uploadPhrases
.map((_, index) => index)
.filter((index) => !usedPhrases.value.has(index));
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)];
usedPhrases.value.add(randomIndex);
return uploadPhrases[randomIndex];
};
const isDangerous = computed(() => hardReset.value);
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
@@ -259,31 +189,17 @@ const handleReinstall = async () => {
}
isLoading.value = true;
uploadProgress.value = 0;
uploadProgress.value = 0;
uploadedBytes.value = 0;
totalBytes.value = mrpackFile.value.size;
currentPhrase.value = getNextPhrase();
phraseInterval = setInterval(() => {
currentPhrase.value = getNextPhrase();
}, 4500);
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
mrpackFile.value,
hardReset.value,
);
onProgress(({ loaded, total, progress }) => {
uploadProgress.value = progress;
onProgress(({ loaded, total }) => {
uploadedBytes.value = loaded;
totalBytes.value = total;
if (phraseInterval && progress >= 100) {
clearInterval(phraseInterval);
phraseInterval = null;
currentPhrase.value = "Installing modpack...";
}
});
try {
@@ -316,10 +232,6 @@ const handleReinstall = async () => {
}
} finally {
isLoading.value = false;
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
}
}
};
const onShow = () => {
@@ -328,15 +240,8 @@ const onShow = () => {
loadingServerCheck.value = false;
isLoading.value = false;
mrpackFile.value = null;
uploadProgress.value = 0;
uploadedBytes.value = 0;
totalBytes.value = 0;
currentPhrase.value = "Uploading...";
usedPhrases.value.clear();
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
}
};
const show = () => mrpackModal.value?.show();
@@ -349,14 +254,4 @@ defineExpose({ show, hide });
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
.phrase-fade-enter-active,
.phrase-fade-leave-active {
transition: opacity 0.3s ease;
}
.phrase-fade-enter-from,
.phrase-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -533,69 +533,6 @@
"profile.user-id": {
"message": "User ID: {id}"
},
"project-member-header.accept": {
"message": "Accept"
},
"project-member-header.decline": {
"message": "Decline"
},
"project-member-header.error": {
"message": "Error"
},
"project-member-header.error-decline": {
"message": "Failed to decline team invitation"
},
"project-member-header.error-join": {
"message": "Failed to accept team invitation"
},
"project-member-header.invitation-no-role": {
"message": "You've been invited to join this project. Please accept or decline the invitation."
},
"project-member-header.invitation-title": {
"message": "Invitation to join project"
},
"project-member-header.invitation-with-role": {
"message": "You've been invited be a member of this project with the role of '{role}'."
},
"project-member-header.publishing-checklist": {
"message": "Publishing checklist"
},
"project-member-header.required": {
"message": "Required"
},
"project-member-header.resubmit-for-review": {
"message": "Resubmit for review"
},
"project-member-header.resubmit-for-review-desc": {
"message": "Your project has been {status} by Modrinth's staff. In most cases, you can resubmit for review after addressing the staff's message."
},
"project-member-header.submit-checklist-tooltip": {
"message": "You must complete the required steps in the publishing checklist!"
},
"project-member-header.submit-for-review": {
"message": "Submit for review"
},
"project-member-header.submit-for-review-desc": {
"message": "Your project is only viewable by members of the project. It must be reviewed by moderators in order to be published."
},
"project-member-header.success": {
"message": "Success"
},
"project-member-header.success-decline": {
"message": "You have declined the team invitation"
},
"project-member-header.success-join": {
"message": "You have joined the project team"
},
"project-member-header.suggestion": {
"message": "Suggestion"
},
"project-member-header.visit-moderation-page": {
"message": "Visit moderation page"
},
"project-member-header.warning": {
"message": "Warning"
},
"project-type.collection.plural": {
"message": "Collections"
},

View File

@@ -22,10 +22,6 @@
"
:on-image-upload="onUploadHandler"
/>
<div v-if="descriptionWarning" class="flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ descriptionWarning }}
</div>
<div class="input-group markdown-disclaimer">
<button
:disabled="!hasChanges"
@@ -42,8 +38,7 @@
</template>
<script lang="ts" setup>
import { SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
import { MIN_DESCRIPTION_CHARS } from "@modrinth/moderation";
import { SaveIcon } from "@modrinth/assets";
import { MarkdownEditor } from "@modrinth/ui";
import { type Project, type TeamMember, TeamMemberPermission } from "@modrinth/utils";
import { computed, ref } from "vue";
@@ -58,17 +53,6 @@ const props = defineProps<{
const description = ref(props.project.body);
const descriptionWarning = computed(() => {
const text = description.value?.trim() || "";
const charCount = text.length;
if (charCount < MIN_DESCRIPTION_CHARS) {
return `It's recommended to have a description with at least ${MIN_DESCRIPTION_CHARS} characters. (${charCount}/${MIN_DESCRIPTION_CHARS})`;
}
return null;
});
const patchRequestPayload = computed(() => {
const payload: {
body?: string;

View File

@@ -82,10 +82,6 @@
<label for="project-summary">
<span class="label__title">Summary</span>
</label>
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ summaryWarning }}
</div>
<div class="textarea-wrapper summary-input">
<textarea
id="project-summary"
@@ -244,18 +240,9 @@
<script setup>
import { formatProjectStatus, formatProjectType } from "@modrinth/utils";
import {
UploadIcon,
SaveIcon,
TrashIcon,
XIcon,
IssuesIcon,
CheckIcon,
TriangleAlertIcon,
} from "@modrinth/assets";
import { UploadIcon, SaveIcon, TrashIcon, XIcon, IssuesIcon, CheckIcon } from "@modrinth/assets";
import { Multiselect } from "vue-multiselect";
import { ConfirmModal, Avatar } from "@modrinth/ui";
import { MIN_SUMMARY_CHARS } from "@modrinth/moderation";
import FileInput from "~/components/ui/FileInput.vue";
const props = defineProps({
@@ -313,17 +300,6 @@ const hasDeletePermission = computed(() => {
return (props.currentMember.permissions & DELETE_PROJECT) === DELETE_PROJECT;
});
const summaryWarning = computed(() => {
const text = summary.value?.trim() || "";
const charCount = text.length;
if (charCount < MIN_SUMMARY_CHARS) {
return `It's recommended to have a summary with at least ${MIN_SUMMARY_CHARS} characters. (${charCount}/${MIN_SUMMARY_CHARS})`;
}
return null;
});
const sideTypes = ["required", "optional", "unsupported"];
const patchData = computed(() => {

View File

@@ -7,16 +7,11 @@
id="project-issue-tracker"
title="A place for users to report bugs, issues, and concerns about your project."
>
<span class="label__title">Issue tracker </span>
<span class="label__title">Issue tracker</span>
<span class="label__description">
A place for users to report bugs, issues, and concerns about your project.
</span>
</label>
<TriangleAlertIcon
v-if="!isIssuesUrlCommon"
v-tooltip="`You're using a link which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-issue-tracker"
v-model="issuesUrl"
@@ -31,16 +26,11 @@
id="project-source-code"
title="A page/repository containing the source code for your project"
>
<span class="label__title">Source code </span>
<span class="label__title">Source code</span>
<span class="label__description">
A page/repository containing the source code for your project
</span>
</label>
<TriangleAlertIcon
v-if="!isSourceUrlCommon"
v-tooltip="`You're using a link which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-source-code"
v-model="sourceUrl"
@@ -71,14 +61,9 @@
</div>
<div class="adjacent-input">
<label id="project-discord-invite" title="An invitation link to your Discord server.">
<span class="label__title">Discord invite </span>
<span class="label__title">Discord invite</span>
<span class="label__description"> An invitation link to your Discord server. </span>
</label>
<TriangleAlertIcon
v-if="!isDiscordUrlCommon"
v-tooltip="`You're using a link which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="project-discord-invite"
v-model="discordUrl"
@@ -138,8 +123,7 @@
<script setup>
import { DropdownSelect } from "@modrinth/ui";
import { SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
import { isCommonUrl, commonLinkDomains } from "@modrinth/moderation";
import { SaveIcon } from "@modrinth/assets";
const tags = useTags();
@@ -169,21 +153,6 @@ const sourceUrl = ref(props.project.source_url);
const wikiUrl = ref(props.project.wiki_url);
const discordUrl = ref(props.project.discord_url);
const isIssuesUrlCommon = computed(() => {
if (!issuesUrl.value || issuesUrl.value.trim().length === 0) return true;
return isCommonUrl(issuesUrl.value, commonLinkDomains.issues);
});
const isSourceUrlCommon = computed(() => {
if (!sourceUrl.value || sourceUrl.value.trim().length === 0) return true;
return isCommonUrl(sourceUrl.value, commonLinkDomains.source);
});
const isDiscordUrlCommon = computed(() => {
if (!discordUrl.value || discordUrl.value.trim().length === 0) return true;
return isCommonUrl(discordUrl.value, commonLinkDomains.discord);
});
const rawDonationLinks = JSON.parse(JSON.stringify(props.project.donation_urls));
rawDonationLinks.push({
id: null,

View File

@@ -6,31 +6,11 @@
<span class="label__title size-card-header">Tags</span>
</h3>
</div>
<div
v-if="tooManyTagsWarning && !allTagsSelectedWarning"
class="my-2 flex items-center gap-1.5 text-orange"
>
<TriangleAlertIcon class="my-auto" />
{{ tooManyTagsWarning }}
</div>
<div v-if="multipleResolutionTagsWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ multipleResolutionTagsWarning }}
</div>
<div v-if="allTagsSelectedWarning" class="my-2 flex items-center gap-1.5 text-red">
<TriangleAlertIcon class="my-auto" />
<span>{{ allTagsSelectedWarning }}</span>
</div>
<p>
Accurate tagging is important to help people find your
{{ formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
that apply.
</p>
<p v-if="project.versions.length === 0" class="known-errors">
Please upload a version first in order to select tags!
</p>
@@ -132,181 +112,145 @@
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { StarIcon, SaveIcon, TriangleAlertIcon } from "@modrinth/assets";
import {
formatCategory,
formatCategoryHeader,
formatProjectType,
sortedCategories,
type Project,
} from "@modrinth/utils";
<script>
import { StarIcon, SaveIcon } from "@modrinth/assets";
import { formatCategory, formatCategoryHeader, formatProjectType } from "@modrinth/utils";
import Checkbox from "~/components/ui/Checkbox.vue";
interface Category {
name: string;
header: string;
icon?: string;
project_type: string;
}
export default defineNuxtComponent({
components: {
Checkbox,
SaveIcon,
StarIcon,
},
props: {
project: {
type: Object,
default() {
return {};
},
},
allMembers: {
type: Array,
default() {
return [];
},
},
currentMember: {
type: Object,
default() {
return null;
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: "main",
title: "An error occurred",
text: "Patch project function not found",
type: "error",
});
};
},
},
},
data() {
return {
selectedTags: this.$sortedCategories().filter(
(x) =>
x.project_type === this.project.actualProjectType &&
(this.project.categories.includes(x.name) ||
this.project.additional_categories.includes(x.name)),
),
featuredTags: this.$sortedCategories().filter(
(x) =>
x.project_type === this.project.actualProjectType &&
this.project.categories.includes(x.name),
),
};
},
computed: {
categoryLists() {
const lists = {};
this.$sortedCategories().forEach((x) => {
if (x.project_type === this.project.actualProjectType) {
const header = x.header;
if (!lists[header]) {
lists[header] = [];
}
lists[header].push(x);
}
});
return lists;
},
patchData() {
const data = {};
// Promote selected categories to featured if there are less than 3 featured
const newFeaturedTags = this.featuredTags.slice();
if (newFeaturedTags.length < 1 && this.selectedTags.length > newFeaturedTags.length) {
const nonFeaturedCategories = this.selectedTags.filter((x) => !newFeaturedTags.includes(x));
interface Props {
project: Project & {
actualProjectType: string;
};
allMembers?: any[];
currentMember?: any;
patchProject?: (data: any) => void;
}
nonFeaturedCategories
.slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
.forEach((x) => newFeaturedTags.push(x));
}
// Convert selected and featured categories to backend-usable arrays
const categories = newFeaturedTags.map((x) => x.name);
const additionalCategories = this.selectedTags
.filter((x) => !newFeaturedTags.includes(x))
.map((x) => x.name);
const tags = useTags();
if (
categories.length !== this.project.categories.length ||
categories.some((value) => !this.project.categories.includes(value))
) {
data.categories = categories;
}
const props = withDefaults(defineProps<Props>(), {
allMembers: () => [],
currentMember: null,
patchProject: () => {
addNotification({
title: "An error occurred",
text: "Patch project function not found",
type: "error",
});
if (
additionalCategories.length !== this.project.additional_categories.length ||
additionalCategories.some((value) => !this.project.additional_categories.includes(value))
) {
data.additional_categories = additionalCategories;
}
return data;
},
hasChanges() {
return Object.keys(this.patchData).length > 0;
},
},
methods: {
formatProjectType,
formatCategoryHeader,
formatCategory,
toggleCategory(category) {
if (this.selectedTags.includes(category)) {
this.selectedTags = this.selectedTags.filter((x) => x !== category);
if (this.featuredTags.includes(category)) {
this.featuredTags = this.featuredTags.filter((x) => x !== category);
}
} else {
this.selectedTags.push(category);
}
},
toggleFeaturedCategory(category) {
if (this.featuredTags.includes(category)) {
this.featuredTags = this.featuredTags.filter((x) => x !== category);
} else {
this.featuredTags.push(category);
}
},
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData);
}
},
},
});
const selectedTags = ref<Category[]>(
sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === props.project.actualProjectType &&
(props.project.categories.includes(x.name) ||
props.project.additional_categories.includes(x.name)),
),
);
const featuredTags = ref<Category[]>(
sortedCategories(tags.value).filter(
(x: Category) =>
x.project_type === props.project.actualProjectType &&
props.project.categories.includes(x.name),
),
);
const categoryLists = computed(() => {
const lists: Record<string, Category[]> = {};
sortedCategories(tags.value).forEach((x: Category) => {
if (x.project_type === props.project.actualProjectType) {
const header = x.header;
if (!lists[header]) {
lists[header] = [];
}
lists[header].push(x);
}
});
return lists;
});
const tooManyTagsWarning = computed(() => {
const tagCount = selectedTags.value.length;
if (tagCount > 5) {
return `You've selected ${tagCount} tags. Consider reducing to 5 or fewer to keep your project focused and easier to discover.`;
}
return null;
});
const multipleResolutionTagsWarning = computed(() => {
if (props.project.project_type !== "resourcepack") return null;
const resolutionTags = selectedTags.value.filter((tag) =>
["16x", "32x", "48x", "64x", "128x", "256x", "512x", "1024x"].includes(tag.name),
);
if (resolutionTags.length > 1) {
return `You've selected ${resolutionTags.length} resolution tags (${resolutionTags.map((t) => t.name).join(", ")}). Resource packs should typically only have one resolution tag.`;
}
return null;
});
const allTagsSelectedWarning = computed(() => {
const categoriesForProjectType = sortedCategories(tags.value).filter(
(x: Category) => x.project_type === props.project.actualProjectType,
);
const totalSelectedTags = selectedTags.value.length;
if (
totalSelectedTags === categoriesForProjectType.length &&
categoriesForProjectType.length > 0
) {
return `You've selected all ${categoriesForProjectType.length} available tags. Please select only the tags that truly apply to your project.`;
}
return null;
});
const patchData = computed(() => {
const data: Record<string, string[]> = {};
// Promote selected categories to featured if there are less than 3 featured
const newFeaturedTags = featuredTags.value.slice();
if (newFeaturedTags.length < 1 && selectedTags.value.length > newFeaturedTags.length) {
const nonFeaturedCategories = selectedTags.value.filter((x) => !newFeaturedTags.includes(x));
nonFeaturedCategories
.slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
.forEach((x) => newFeaturedTags.push(x));
}
// Convert selected and featured categories to backend-usable arrays
const categories = newFeaturedTags.map((x) => x.name);
const additionalCategories = selectedTags.value
.filter((x) => !newFeaturedTags.includes(x))
.map((x) => x.name);
if (
categories.length !== props.project.categories.length ||
categories.some((value) => !props.project.categories.includes(value))
) {
data.categories = categories;
}
if (
additionalCategories.length !== props.project.additional_categories.length ||
additionalCategories.some((value) => !props.project.additional_categories.includes(value))
) {
data.additional_categories = additionalCategories;
}
return data;
});
const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0;
});
const toggleCategory = (category: Category) => {
if (selectedTags.value.includes(category)) {
selectedTags.value = selectedTags.value.filter((x) => x !== category);
if (featuredTags.value.includes(category)) {
featuredTags.value = featuredTags.value.filter((x) => x !== category);
}
} else {
selectedTags.value.push(category);
}
};
const toggleFeaturedCategory = (category: Category) => {
if (featuredTags.value.includes(category)) {
featuredTags.value = featuredTags.value.filter((x) => x !== category);
} else {
featuredTags.value.push(category);
}
};
const saveChanges = () => {
if (hasChanges.value) {
props.patchProject(patchData.value);
}
};
</script>
<style lang="scss" scoped>
.label__title {
display: flex;

View File

@@ -150,9 +150,26 @@
</template>
</span>
<span class="text-sm text-secondary">
<span
v-if="charge.status === 'cancelled' && $dayjs(charge.due).isBefore($dayjs())"
class="font-bold"
>
Ended:
</span>
<span v-else-if="charge.status === 'cancelled'" class="font-bold">Ends:</span>
<span v-else-if="charge.type === 'refund'" class="font-bold">Issued:</span>
<span v-else class="font-bold">Due:</span>
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
</span>
<span v-if="charge.last_attempt != null" class="text-sm text-secondary">
<span v-if="charge.status === 'failed'" class="font-bold">Last attempt:</span>
<span v-else class="font-bold">Charged:</span>
{{ dayjs(charge.last_attempt).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary"
>({{ formatRelativeTime(charge.last_attempt) }})
</span>
</span>
<div class="flex w-full items-center gap-1 text-xs text-secondary">
{{ charge.status }}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
"describe": {
"columns": [
{
@@ -102,5 +102,5 @@
true
]
},
"hash": "99cca53fd3f35325e2da3b671532bf98b8c7ad8e7cb9158e4eb9c5bac66d20b2"
"hash": "cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055"
}

View File

@@ -197,7 +197,7 @@ impl DBCharge {
) -> Result<Option<DBCharge>, DatabaseError> {
let user_subscription_id = user_subscription_id.0;
let res = select_charges_with_predicate!(
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')",
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
user_subscription_id
)
.fetch_optional(exec)

View File

@@ -1,2 +1,10 @@
# SQLite database file location
DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
MODRINTH_URL=http://localhost:3000/
MODRINTH_API_URL=http://127.0.0.1:8000/v2/
MODRINTH_API_URL_V3=http://127.0.0.1:8000/v3/
MODRINTH_SOCKET_URL=ws://127.0.0.1:8000/
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
# in your system and run `cargo sqlx database setup` to generate an empty database that
# can be used for developing the app DB schema
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db

View File

@@ -0,0 +1,10 @@
MODRINTH_URL=https://modrinth.com/
MODRINTH_API_URL=https://api.modrinth.com/v2/
MODRINTH_API_URL_V3=https://api.modrinth.com/v3/
MODRINTH_SOCKET_URL=wss://api.modrinth.com/
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
# in your system and run `cargo sqlx database setup` to generate an empty database that
# can be used for developing the app DB schema
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db

View File

@@ -0,0 +1,10 @@
MODRINTH_URL=https://staging.modrinth.com/
MODRINTH_API_URL=https://staging-api.modrinth.com/v2/
MODRINTH_API_URL_V3=https://staging-api.modrinth.com/v3/
MODRINTH_SOCKET_URL=wss://staging-api.modrinth.com/
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
# in your system and run `cargo sqlx database setup` to generate an empty database that
# can be used for developing the app DB schema
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar\n FROM settings\n ",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version\n FROM settings\n ",
"describe": {
"columns": [
{
@@ -142,6 +142,16 @@
"name": "toggle_sidebar",
"ordinal": 27,
"type_info": "Integer"
},
{
"name": "skipped_update",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "pending_update_toast_for_version",
"ordinal": 29,
"type_info": "Text"
}
],
"parameters": {
@@ -175,8 +185,10 @@
true,
false,
null,
false
false,
true,
true
]
},
"hash": "5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca"
"hash": "30fecc13c6ea4da1e99e35d307aa8e7c4e7f15ea99527e7974619ef8ed946abe"
}

View File

@@ -41,7 +41,7 @@
{
"name": "display_claims!: serde_json::Value",
"ordinal": 7,
"type_info": "Null"
"type_info": "Text"
}
],
"parameters": {

View File

@@ -1,12 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28\n ",
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28,\n\n skipped_update = $29,\n pending_update_toast_for_version = $30\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 28
"Right": 30
},
"nullable": []
},
"hash": "3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c"
"hash": "cd7c9f394e4f0ec8fd0043352f7382ac2570aaa096cd2ebb4de990f4d42cc5c9"
}

View File

@@ -82,6 +82,7 @@ ariadne.workspace = true
winreg.workspace = true
[build-dependencies]
dotenvy.workspace = true
dunce.workspace = true
[features]

View File

@@ -4,12 +4,31 @@ use std::process::{Command, exit};
use std::{env, fs};
fn main() {
println!("cargo::rerun-if-changed=.env");
println!("cargo::rerun-if-changed=java/gradle");
println!("cargo::rerun-if-changed=java/src");
println!("cargo::rerun-if-changed=java/build.gradle.kts");
println!("cargo::rerun-if-changed=java/settings.gradle.kts");
println!("cargo::rerun-if-changed=java/gradle.properties");
set_env();
build_java_jars();
}
fn set_env() {
for (var_name, var_value) in
dotenvy::dotenv_iter().into_iter().flatten().flatten()
{
if var_name == "DATABASE_URL" {
// The sqlx database URL is a build-time detail that should not be exposed to the crate
continue;
}
println!("cargo::rustc-env={var_name}={var_value}");
}
}
fn build_java_jars() {
let out_dir =
dunce::canonicalize(PathBuf::from(env::var_os("OUT_DIR").unwrap()))
.unwrap();
@@ -37,6 +56,7 @@ fn main() {
.current_dir(dunce::canonicalize("java").unwrap())
.status()
.expect("Failed to wait on Gradle build");
if !exit_status.success() {
println!("cargo::error=Gradle build failed with {exit_status}");
exit(exit_status.code().unwrap_or(1));

View File

@@ -0,0 +1,2 @@
ALTER TABLE settings
ADD COLUMN skipped_update TEXT NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE settings
ADD COLUMN pending_update_toast_for_version TEXT NULL;

View File

@@ -1,7 +1,7 @@
use crate::state::ModrinthCredentials;
#[tracing::instrument]
pub fn authenticate_begin_flow() -> String {
pub fn authenticate_begin_flow() -> &'static str {
crate::state::get_login_url()
}

View File

@@ -1,13 +0,0 @@
//! Configuration structs
// pub const MODRINTH_URL: &str = "https://staging.modrinth.com/";
// pub const MODRINTH_API_URL: &str = "https://staging-api.modrinth.com/v2/";
// pub const MODRINTH_API_URL_V3: &str = "https://staging-api.modrinth.com/v3/";
pub const MODRINTH_URL: &str = "https://modrinth.com/";
pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2/";
pub const MODRINTH_API_URL_V3: &str = "https://api.modrinth.com/v3/";
pub const MODRINTH_SOCKET_URL: &str = "wss://api.modrinth.com/";
pub const META_URL: &str = "https://launcher-meta.modrinth.com/";

View File

@@ -176,7 +176,6 @@ pub enum LoadingBarType {
import_location: PathBuf,
profile_name: String,
},
CheckingForUpdates,
LauncherUpdate {
version: String,
current_version: String,

View File

@@ -11,7 +11,6 @@ and launching Modrinth mod packs
mod util;
mod api;
mod config;
mod error;
mod event;
mod launcher;
@@ -26,3 +25,9 @@ pub use event::{
};
pub use logger::start_logger;
pub use state::State;
pub const LAUNCHER_USER_AGENT: &str = concat!(
"modrinth/theseus/",
env!("CARGO_PKG_VERSION"),
" (support@modrinth.com)"
);

View File

@@ -25,12 +25,11 @@ pub fn start_logger() -> Option<()> {
.unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("theseus=info,theseus_gui=info")
});
let subscriber = tracing_subscriber::registry()
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(filter)
.with(tracing_error::ErrorLayer::default());
tracing::subscriber::set_global_default(subscriber)
.expect("setting default subscriber failed");
.with(tracing_error::ErrorLayer::default())
.init();
Some(())
}
@@ -76,7 +75,7 @@ pub fn start_logger() -> Option<()> {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("theseus=info"));
let subscriber = tracing_subscriber::registry()
tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.with_writer(file)
@@ -84,10 +83,8 @@ pub fn start_logger() -> Option<()> {
.with_timer(ChronoLocal::rfc_3339()),
)
.with(filter)
.with(tracing_error::ErrorLayer::default());
tracing::subscriber::set_global_default(subscriber)
.expect("Setting default subscriber failed");
.with(tracing_error::ErrorLayer::default())
.init();
Some(())
}

View File

@@ -1,4 +1,3 @@
use crate::config::{META_URL, MODRINTH_API_URL, MODRINTH_API_URL_V3};
use crate::state::ProjectType;
use crate::util::fetch::{FetchSemaphore, fetch_json, sha1_async};
use chrono::{DateTime, Utc};
@@ -8,6 +7,7 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::collections::HashMap;
use std::env;
use std::fmt::Display;
use std::hash::Hash;
use std::path::{Path, PathBuf};
@@ -945,7 +945,7 @@ impl CachedEntry {
CacheValueType::Project => {
fetch_original_values!(
Project,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"projects",
CacheValue::Project
)
@@ -953,7 +953,7 @@ impl CachedEntry {
CacheValueType::Version => {
fetch_original_values!(
Version,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"versions",
CacheValue::Version
)
@@ -961,7 +961,7 @@ impl CachedEntry {
CacheValueType::User => {
fetch_original_values!(
User,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"users",
CacheValue::User
)
@@ -969,7 +969,7 @@ impl CachedEntry {
CacheValueType::Team => {
let mut teams = fetch_many_batched::<Vec<TeamMember>>(
Method::GET,
MODRINTH_API_URL_V3,
env!("MODRINTH_API_URL_V3"),
"teams?ids=",
&keys,
fetch_semaphore,
@@ -1008,7 +1008,7 @@ impl CachedEntry {
CacheValueType::Organization => {
let mut orgs = fetch_many_batched::<Organization>(
Method::GET,
MODRINTH_API_URL_V3,
env!("MODRINTH_API_URL_V3"),
"organizations?ids=",
&keys,
fetch_semaphore,
@@ -1063,7 +1063,7 @@ impl CachedEntry {
CacheValueType::File => {
let mut versions = fetch_json::<HashMap<String, Version>>(
Method::POST,
&format!("{MODRINTH_API_URL}version_files"),
concat!(env!("MODRINTH_API_URL"), "version_files"),
None,
Some(serde_json::json!({
"algorithm": "sha1",
@@ -1119,7 +1119,11 @@ impl CachedEntry {
.map(|x| {
(
x.key().to_string(),
format!("{META_URL}{}/v0/manifest.json", x.key()),
format!(
"{}{}/v0/manifest.json",
env!("MODRINTH_LAUNCHER_META_URL"),
x.key()
),
)
})
.collect::<Vec<_>>();
@@ -1154,7 +1158,7 @@ impl CachedEntry {
CacheValueType::MinecraftManifest => {
fetch_original_value!(
MinecraftManifest,
META_URL,
env!("MODRINTH_LAUNCHER_META_URL"),
format!(
"minecraft/v{}/manifest.json",
daedalus::minecraft::CURRENT_FORMAT_VERSION
@@ -1165,7 +1169,7 @@ impl CachedEntry {
CacheValueType::Categories => {
fetch_original_value!(
Categories,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"tag/category",
CacheValue::Categories
)
@@ -1173,7 +1177,7 @@ impl CachedEntry {
CacheValueType::ReportTypes => {
fetch_original_value!(
ReportTypes,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"tag/report_type",
CacheValue::ReportTypes
)
@@ -1181,7 +1185,7 @@ impl CachedEntry {
CacheValueType::Loaders => {
fetch_original_value!(
Loaders,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"tag/loader",
CacheValue::Loaders
)
@@ -1189,7 +1193,7 @@ impl CachedEntry {
CacheValueType::GameVersions => {
fetch_original_value!(
GameVersions,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"tag/game_version",
CacheValue::GameVersions
)
@@ -1197,7 +1201,7 @@ impl CachedEntry {
CacheValueType::DonationPlatforms => {
fetch_original_value!(
DonationPlatforms,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"tag/donation_platform",
CacheValue::DonationPlatforms
)
@@ -1297,14 +1301,12 @@ impl CachedEntry {
}
});
let version_update_url =
format!("{MODRINTH_API_URL}version_files/update");
let variations =
futures::future::try_join_all(filtered_keys.iter().map(
|((loaders_key, game_version), hashes)| {
fetch_json::<HashMap<String, Version>>(
Method::POST,
&version_update_url,
concat!(env!("MODRINTH_API_URL"), "version_files/update"),
None,
Some(serde_json::json!({
"algorithm": "sha1",
@@ -1368,7 +1370,11 @@ impl CachedEntry {
.map(|x| {
(
x.key().to_string(),
format!("{MODRINTH_API_URL}search{}", x.key()),
format!(
"{}search{}",
env!("MODRINTH_API_URL"),
x.key()
),
)
})
.collect::<Vec<_>>();

View File

@@ -1,4 +1,4 @@
use crate::config::{MODRINTH_API_URL_V3, MODRINTH_SOCKET_URL};
use crate::LAUNCHER_USER_AGENT;
use crate::data::ModrinthCredentials;
use crate::event::FriendPayload;
use crate::event::emit::emit_friend;
@@ -77,18 +77,15 @@ impl FriendsSocket {
if let Some(credentials) = credentials {
let mut request = format!(
"{MODRINTH_SOCKET_URL}_internal/launcher_socket?code={}",
"{}_internal/launcher_socket?code={}",
env!("MODRINTH_SOCKET_URL"),
credentials.session
)
.into_client_request()?;
let user_agent = format!(
"modrinth/theseus/{} (support@modrinth.com)",
env!("CARGO_PKG_VERSION")
);
request.headers_mut().insert(
"User-Agent",
HeaderValue::from_str(&user_agent).unwrap(),
HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(),
);
let res = connect_async(request).await;
@@ -303,7 +300,7 @@ impl FriendsSocket {
) -> crate::Result<Vec<UserFriend>> {
fetch_json(
Method::GET,
&format!("{MODRINTH_API_URL_V3}friends"),
concat!(env!("MODRINTH_API_URL_V3"), "friends"),
None,
None,
semaphore,
@@ -328,7 +325,7 @@ impl FriendsSocket {
) -> crate::Result<()> {
fetch_advanced(
Method::POST,
&format!("{MODRINTH_API_URL_V3}friend/{user_id}"),
&format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")),
None,
None,
None,
@@ -349,7 +346,7 @@ impl FriendsSocket {
) -> crate::Result<()> {
fetch_advanced(
Method::DELETE,
&format!("{MODRINTH_API_URL_V3}friend/{user_id}"),
&format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")),
None,
None,
None,

View File

@@ -1,4 +1,3 @@
use crate::config::{MODRINTH_API_URL, MODRINTH_URL};
use crate::state::{CacheBehaviour, CachedEntry};
use crate::util::fetch::{FetchSemaphore, fetch_advanced};
use chrono::{DateTime, Duration, TimeZone, Utc};
@@ -31,7 +30,7 @@ impl ModrinthCredentials {
let resp = fetch_advanced(
Method::POST,
&format!("{MODRINTH_API_URL}session/refresh"),
concat!(env!("MODRINTH_API_URL"), "session/refresh"),
None,
None,
Some(("Authorization", &*creds.session)),
@@ -190,8 +189,8 @@ impl ModrinthCredentials {
}
}
pub fn get_login_url() -> String {
format!("{MODRINTH_URL}auth/sign-in?launcher=true")
pub const fn get_login_url() -> &'static str {
concat!(env!("MODRINTH_URL"), "auth/sign-in?launcher=true")
}
pub async fn finish_login_flow(
@@ -216,7 +215,7 @@ async fn fetch_info(
) -> crate::Result<crate::state::cache::User> {
let result = fetch_advanced(
Method::GET,
&format!("{MODRINTH_API_URL}user"),
concat!(env!("MODRINTH_API_URL"), "user"),
None,
None,
Some(("Authorization", token)),

View File

@@ -38,6 +38,9 @@ pub struct Settings {
pub developer_mode: bool,
pub feature_flags: HashMap<FeatureFlag, bool>,
pub skipped_update: Option<String>,
pub pending_update_toast_for_version: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, Hash, PartialEq)]
@@ -63,7 +66,8 @@ impl Settings {
json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,
mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,
hook_pre_launch, hook_wrapper, hook_post_exit,
custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar
custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,
skipped_update, pending_update_toast_for_version
FROM settings
"
)
@@ -117,6 +121,9 @@ impl Settings {
.as_ref()
.and_then(|x| serde_json::from_str(x).ok())
.unwrap_or_default(),
skipped_update: res.skipped_update,
pending_update_toast_for_version: res
.pending_update_toast_for_version,
})
}
@@ -170,7 +177,10 @@ impl Settings {
toggle_sidebar = $26,
feature_flags = $27,
hide_nametag_skins_page = $28
hide_nametag_skins_page = $28,
skipped_update = $29,
pending_update_toast_for_version = $30
",
max_concurrent_writes,
max_concurrent_downloads,
@@ -199,7 +209,9 @@ impl Settings {
self.migrated,
self.toggle_sidebar,
feature_flags,
self.hide_nametag_skins_page
self.hide_nametag_skins_page,
self.skipped_update,
self.pending_update_toast_for_version,
)
.execute(exec)
.await?;

View File

@@ -1,6 +1,6 @@
//! Functions for fetching information from the Internet
use super::io::{self, IOError};
use crate::config::{MODRINTH_API_URL, MODRINTH_API_URL_V3};
use crate::LAUNCHER_USER_AGENT;
use crate::event::LoadingBarId;
use crate::event::emit::emit_loading;
use bytes::Bytes;
@@ -20,11 +20,8 @@ pub struct FetchSemaphore(pub Semaphore);
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
let mut headers = reqwest::header::HeaderMap::new();
let header = reqwest::header::HeaderValue::from_str(&format!(
"modrinth/theseus/{} (support@modrinth.com)",
env!("CARGO_PKG_VERSION")
))
.unwrap();
let header =
reqwest::header::HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap();
headers.insert(reqwest::header::USER_AGENT, header);
reqwest::Client::builder()
.tcp_keepalive(Some(time::Duration::from_secs(10)))
@@ -84,8 +81,8 @@ pub async fn fetch_advanced(
.as_ref()
.is_none_or(|x| &*x.0.to_lowercase() != "authorization")
&& (url.starts_with("https://cdn.modrinth.com")
|| url.starts_with(MODRINTH_API_URL)
|| url.starts_with(MODRINTH_API_URL_V3))
|| url.starts_with(env!("MODRINTH_API_URL"))
|| url.starts_with(env!("MODRINTH_API_URL_V3")))
{
crate::state::ModrinthCredentials::get_active(exec).await?
} else {

View File

@@ -132,6 +132,7 @@ import _RadioButtonCheckedIcon from './icons/radio-button-checked.svg?component'
import _RadioButtonIcon from './icons/radio-button.svg?component'
import _ReceiptTextIcon from './icons/receipt-text.svg?component'
import _RedoIcon from './icons/redo.svg?component'
import _RefreshCwIcon from './icons/refresh-cw.svg?component'
import _ReplyIcon from './icons/reply.svg?component'
import _ReportIcon from './icons/report.svg?component'
import _RestoreIcon from './icons/restore.svg?component'
@@ -323,6 +324,7 @@ export const RadioButtonCheckedIcon = _RadioButtonCheckedIcon
export const RadioButtonIcon = _RadioButtonIcon
export const ReceiptTextIcon = _ReceiptTextIcon
export const RedoIcon = _RedoIcon
export const RefreshCwIcon = _RefreshCwIcon
export const ReplyIcon = _ReplyIcon
export const ReportIcon = _ReportIcon
export const RestoreIcon = _RestoreIcon

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-refresh-cw-icon lucide-refresh-cw"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>

After

Width:  |  Height:  |  Size: 411 B

View File

@@ -1,8 +1,6 @@
<!-- TODO: After checklist v1.5, move everything into src directory. -->
# @modrinth/moderation
This package contains both the moderation checklist system used by moderators for reviewing projects on Modrinth, and the publishing checklist (nag system) that provides automated feedback to project authors during the submission process.
This package contains the moderation checklist system used for reviewing projects on Modrinth. It provides a structured and transparent way to define moderation stages, actions, and messages that are displayed to moderators during the review process.
## Structure
@@ -11,31 +9,22 @@ The package is organized as follows:
```
/packages/moderation/
├── data/
│ ├── checklist.ts # Main moderation checklist definition - imports and exports all stages
│ ├── messages/ # Markdown files containing message templates for moderation
│ ├── checklist.ts # Main checklist definition - imports and exports all stages
│ ├── messages/ # Markdown files containing message templates
│ │ ├── title/ # Messages for the title stage
│ │ ├── description/ # Messages for the description stage
│ │ └── ... # One directory per stage
── stages/ # Moderation stage definition files
├── title.ts # Title stage definition
├── description.ts # Description stage definition
└── ... # One file per stage
│ └── nags/ # Publishing checklist (nag system) files
│ ├── core.ts # Core nags (required fields, basic validation)
│ ├── core.i18n.ts # Internationalization messages for core nags
│ └── ...
── stages/ # Stage definition files
├── title.ts # Title stage definition
├── description.ts # Description stage definition
└── ... # One file per stage
└── types/ # Type definitions
├── actions.ts # Action-related types (moderation)
├── messages.ts # Message-related types (moderation)
── stage.ts # Stage-related types (moderation)
└── nags.ts # Nag-related types (publishing checklist)
├── actions.ts # Action-related types
├── messages.ts # Message-related types
── stage.ts # Stage-related types
```
## Moderation Checklist System
The moderation checklist provides a structured and transparent way to define moderation stages, actions, and messages that are displayed to moderators during the review process.
### Stages
## Stages
A stage represents a discrete step in the moderation process, like checking a project's title, description, or links. Each stage has:
@@ -46,7 +35,7 @@ A stage represents a discrete step in the moderation process, like checking a pr
Stages are defined in individual files in the `data/stages` directory and are assembled into the complete checklist in `data/checklist.ts`.
### Actions
## Actions
Actions represent decisions moderators can make for each stage. They can be buttons, dropdowns, toggles, etc. Actions can have:
@@ -58,11 +47,11 @@ Actions represent decisions moderators can make for each stage. They can be butt
Each action requires a unique `id` field that is used for conditional logic and action relationships. The `suggestedStatus` and `severity` fields help determine the overall moderation outcome.
### Messages
## Messages
Messages are the actual text that will be included in communications to project authors. To promote maintainability and reuse, messages are stored as Markdown files in the `data/messages` directory, organized by stage.
#### Variable replacement
### Variable replacement
You can use variables in your messages that will be replaced with user input:
@@ -92,11 +81,11 @@ More text after the variable.
The `%MESSAGE%` placeholder will be replaced with the text entered by the moderator.
### Conditional logic
## Conditional logic
The moderation system supports conditional behavior that changes based on the selection of other actions.
#### Conditional messages
### Conditional messages
You can define different messages for an action based on other selected actions:
@@ -119,7 +108,7 @@ You can define different messages for an action based on other selected actions:
}
```
#### Enabling and disabling actions
### Enabling and disabling actions
Actions can enable or disable other actions when selected:
@@ -142,7 +131,7 @@ Actions can enable or disable other actions when selected:
}
```
#### Conditional text inputs
### Conditional text inputs
Text inputs can be conditionally shown based on selected actions:
@@ -158,101 +147,3 @@ relevantExtraInput: [
},
]
```
## Publishing Checklist (Nag System)
The nag system provides automated feedback to project authors during the submission process, helping them improve their projects before they reach moderation. It analyzes project data and provides suggestions, warnings, and requirements.
### Nags
A nag represents a specific issue or suggestion for improvement. Each nag has:
- A unique `id` for identification
- A `title` and `description` displayed to the user
- A `status` indicating severity: `'required'`, `'warning'`, or `'suggestion'`
- A `shouldShow` function that determines when the nag should be displayed
- An optional `link` to help users address the issue
### Internationalization
Each nag category has a corresponding `.i18n.ts` file containing message definitions:
```typescript
// Example from core.i18n.ts
export default defineMessages({
addDescriptionTitle: {
id: 'nags.add-description.title',
defaultMessage: 'Add a description',
},
addDescriptionDescription: {
id: 'nags.add-description.description',
defaultMessage:
"A description that clearly describes the project's purpose and function is required.",
},
})
```
If you want to use context in the messages, you can do so like this:
```typescript
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(messages.descriptionTooShortDescription, {
length: context.project.body?.length || 0,
minChars: MIN_DESCRIPTION_CHARS,
})
}
```
### Nag Context
The `NagContext` type provides access to:
- `project`: Current project data
- `versions`: Project versions
- `tags`: Frontend "tags" (generated state)
- `currentRoute`: Current page route
- and other data...
### Adding New Nags
To add a new nag:
1. Add the nag definition to the appropriate category file (or make a new category file and add it to `data/nags.ts`)
2. Add corresponding i18n messages to the `.i18n.ts` file
3. Implement the `shouldShow` logic based on project state
4. Add appropriate links to help users resolve the issue
5. Run `pnpm run fix` to fix lint issues & generate the root locale index.json file.
Example:
```typescript
// In description.ts
{
id: 'new-nag',
title: messages.newNagTitle,
description: messages.newNagDescription,
status: 'warning',
shouldShow: (context: NagContext) => {
// Your validation logic here
return someCondition
},
link: {
path: 'settings/description',
title: messages.editDescriptionTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
}
```
```typescript
// In description.i18n.ts
newNagTitle: {
id: 'nags.new-nag.title',
defaultMessage: 'New Nag Title',
},
newNagDescription: {
id: 'nags.new-nag.description',
defaultMessage: 'Description of the new nag issue.',
```

View File

@@ -1,7 +0,0 @@
import type { Nag } from '../types/nags'
import { coreNags } from './nags/core'
import { descriptionNags } from './nags/description'
import { linksNags } from './nags/links'
import { tagsNags } from './nags/tags'
export default [...coreNags, ...linksNags, ...descriptionNags, ...tagsNags] as Nag[]

View File

@@ -1,116 +0,0 @@
import { defineMessages } from '@vintl/vintl'
export default defineMessages({
moderatorFeedbackTitle: {
id: 'nags.moderator-feedback.title',
defaultMessage: 'Review moderator feedback',
},
moderatorFeedbackDescription: {
id: 'nags.moderator-feedback.description',
defaultMessage:
'Review any feedback from moderators regarding your project before resubmitting.',
},
moderationTitle: {
id: 'nags.moderation.title',
defaultMessage: 'Visit moderation thread',
},
uploadVersionTitle: {
id: 'nags.upload-version.title',
defaultMessage: 'Upload a version',
},
uploadVersionDescription: {
id: 'nags.upload-version.description',
defaultMessage: 'At least one version is required for a project to be submitted for review.',
},
versionsTitle: {
id: 'nags.versions.title',
defaultMessage: 'Visit versions page',
},
addDescriptionTitle: {
id: 'nags.add-description.title',
defaultMessage: 'Add a description',
},
addDescriptionDescription: {
id: 'nags.add-description.description',
defaultMessage:
"A description that clearly describes the project's purpose and function is required.",
},
settingsDescriptionTitle: {
id: 'nags.settings.description.title',
defaultMessage: 'Visit description settings',
},
addIconTitle: {
id: 'nags.add-icon.title',
defaultMessage: 'Add an icon',
},
addIconDescription: {
id: 'nags.add-icon.description',
defaultMessage:
'Your project should have a nice-looking icon to uniquely identify your project at a glance.',
},
settingsTitle: {
id: 'nags.settings.title',
defaultMessage: 'Visit general settings',
},
featureGalleryImageTitle: {
id: 'nags.feature-gallery-image.title',
defaultMessage: 'Feature a gallery image',
},
featureGalleryImageDescription: {
id: 'nags.feature-gallery-image.description',
defaultMessage: 'Featured gallery images may be the first impression of many users.',
},
galleryTitle: {
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
},
selectTagsTitle: {
id: 'nags.select-tags.title',
defaultMessage: 'Select tags',
},
selectTagsDescription: {
id: 'nags.select-tags.description',
defaultMessage: 'Select all tags that apply to your project.',
},
settingsTagsTitle: {
id: 'nags.settings.tags.title',
defaultMessage: 'Visit tag settings',
},
addLinksTitle: {
id: 'nags.add-links.title',
defaultMessage: 'Add external links',
},
addLinksDescription: {
id: 'nags.add-links.description',
defaultMessage:
'Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite.',
},
settingsLinksTitle: {
id: 'nags.settings.links.title',
defaultMessage: 'Visit links settings',
},
selectEnvironmentsTitle: {
id: 'nags.select-environments.title',
defaultMessage: 'Select supported environments',
},
selectEnvironmentsDescription: {
id: 'nags.select-environments.description',
defaultMessage: `Select if the {projectType} functions on the client-side and/or server-side.`,
},
settingsEnvironmentsTitle: {
id: 'nags.settings.environments.title',
defaultMessage: 'Visit general settings',
},
selectLicenseTitle: {
id: 'nags.select-license.title',
defaultMessage: 'Select license',
},
selectLicenseDescription: {
id: 'nags.select-license.description',
defaultMessage: 'Select the license your {projectType} is distributed under.',
},
settingsLicenseTitle: {
id: 'nags.settings.license.title',
defaultMessage: 'Visit license settings',
},
})

View File

@@ -1,151 +0,0 @@
import type { Nag, NagContext } from '../../types/nags'
import { formatProjectType } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import messages from './core.i18n'
export const coreNags: Nag[] = [
{
id: 'moderator-feedback',
title: messages.moderatorFeedbackTitle,
description: messages.moderatorFeedbackDescription,
status: 'suggestion',
shouldShow: (context: NagContext) =>
context.tags.rejectedStatuses.includes(context.project.status),
link: {
path: 'moderation',
title: messages.moderationTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-moderation',
},
},
{
id: 'upload-version',
title: messages.uploadVersionTitle,
description: messages.uploadVersionDescription,
status: 'required',
shouldShow: (context: NagContext) => context.versions.length < 1,
link: {
path: 'versions',
title: messages.versionsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-versions',
},
},
{
id: 'add-description',
title: messages.addDescriptionTitle,
description: messages.addDescriptionDescription,
status: 'required',
shouldShow: (context: NagContext) =>
context.project.body === '' || context.project.body.startsWith('# Placeholder description'),
link: {
path: 'settings/description',
title: messages.settingsDescriptionTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'add-icon',
title: messages.addIconTitle,
description: messages.addIconDescription,
status: 'suggestion',
shouldShow: (context: NagContext) => !context.project.icon_url,
link: {
path: 'settings',
title: messages.settingsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'feature-gallery-image',
title: messages.featureGalleryImageTitle,
description: messages.featureGalleryImageDescription,
status: 'suggestion',
shouldShow: (context: NagContext) => {
const featuredGalleryImage = context.project.gallery?.find((img) => img.featured)
return context.project?.gallery?.length === 0 || !featuredGalleryImage
},
link: {
path: 'gallery',
title: messages.galleryTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-gallery',
},
},
{
id: 'select-tags',
title: messages.selectTagsTitle,
description: messages.selectTagsDescription,
status: 'suggestion',
shouldShow: (context: NagContext) =>
context.project.versions.length > 0 && context.project.categories.length < 1,
link: {
path: 'settings/tags',
title: messages.settingsTagsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
},
},
{
id: 'add-links',
title: messages.addLinksTitle,
description: messages.addLinksDescription,
status: 'suggestion',
shouldShow: (context: NagContext) =>
!(
context.project.issues_url ||
context.project.source_url ||
context.project.wiki_url ||
context.project.discord_url ||
context.project.donation_urls.length > 0
),
link: {
path: 'settings/links',
title: messages.settingsLinksTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
{
id: 'select-environments',
title: messages.selectEnvironmentsTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(messages.selectEnvironmentsDescription, {
projectType: formatProjectType(context.project.project_type).toLowerCase(),
})
},
status: 'required',
shouldShow: (context: NagContext) => {
const excludedTypes = ['resourcepack', 'plugin', 'shader', 'datapack']
return (
context.project.versions.length > 0 &&
!excludedTypes.includes(context.project.project_type) &&
(context.project.client_side === 'unknown' ||
context.project.server_side === 'unknown' ||
(context.project.client_side === 'unsupported' &&
context.project.server_side === 'unsupported'))
)
},
link: {
path: 'settings',
title: messages.settingsEnvironmentsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'select-license',
title: messages.selectLicenseTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(messages.selectLicenseDescription, {
projectType: formatProjectType(context.project.project_type).toLowerCase(),
})
},
status: 'required',
shouldShow: (context: NagContext) => context.project.license.id === 'LicenseRef-Unknown',
link: {
path: 'settings/license',
title: messages.settingsLicenseTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-license',
},
},
]

View File

@@ -1,88 +0,0 @@
import { defineMessages } from '@vintl/vintl'
export default defineMessages({
descriptionTooShortTitle: {
id: 'nags.description-too-short.title',
defaultMessage: 'Description may be insufficient',
},
descriptionTooShortDescription: {
id: 'nags.description-too-short.description',
defaultMessage:
"Your description is {length} characters. It's recommended to have at least {minChars} characters to provide users with enough information about your project.",
},
longHeadersTitle: {
id: 'nags.long-headers.title',
defaultMessage: 'Headers are too long',
},
longHeadersDescription: {
id: 'nags.long-headers.description',
defaultMessage:
'{count, plural, one {# header} other {# headers}} in your description {count, plural, one {is} other {are}} too long. Headers should be concise and act as section titles, not full sentences.',
},
summaryTooShortTitle: {
id: 'nags.summary-too-short.title',
defaultMessage: 'Summary may be insufficient',
},
summaryTooShortDescription: {
id: 'nags.summary-too-short.description',
defaultMessage:
"Your summary is {length} characters. It's recommended to have at least {minChars} characters to provide users with enough information about your project.",
},
minecraftTitleClauseTitle: {
id: 'nags.minecraft-title-clause.title',
defaultMessage: 'Title contains "Minecraft"',
},
minecraftTitleClauseDescription: {
id: 'nags.minecraft-title-clause.description',
defaultMessage:
'Please remove "Minecraft" from your title. You cannot use "Minecraft" in your title for legal reasons.',
},
titleContainsTechnicalInfoTitle: {
id: 'nags.title-contains-technical-info.title',
defaultMessage: 'Title contains loader or version info',
},
titleContainsTechnicalInfoDescription: {
id: 'nags.title-contains-technical-info.description',
defaultMessage:
'Removing these helps keep titles clean and makes your project easier to find. Version and loader information is automatically displayed alongside your project.',
},
summarySameAsTitleTitle: {
id: 'nags.summary-same-as-title.title',
defaultMessage: 'Summary is project name',
},
summarySameAsTitleDescription: {
id: 'nags.summary-same-as-title.description',
defaultMessage:
"Your summary is the same as your project name. Please change it. It's recommended to have a unique summary to provide more context about your project.",
},
imageHeavyDescriptionTitle: {
id: 'nags.image-heavy-description.title',
defaultMessage: 'Description is mostly images',
},
imageHeavyDescriptionDescription: {
id: 'nags.image-heavy-description.description',
defaultMessage:
'Please add more descriptive text to help users understand your project, especially those using screen readers or with slow internet connections.',
},
missingAltTextTitle: {
id: 'nags.missing-alt-text.title',
defaultMessage: 'Images missing alt text',
},
missingAltTextDescription: {
id: 'nags.missing-alt-text.description',
defaultMessage:
'Some of your images are missing alt text, which is important for accessibility, especially for visually impaired users.',
},
editDescriptionTitle: {
id: 'nags.edit-description.title',
defaultMessage: 'Edit description',
},
editSummaryTitle: {
id: 'nags.edit-summary.title',
defaultMessage: 'Edit summary',
},
editTitleTitle: {
id: 'nags.edit-title.title',
defaultMessage: 'Edit title',
},
})

View File

@@ -1,226 +0,0 @@
import type { Nag, NagContext } from '../../types/nags'
import { useVIntl } from '@vintl/vintl'
import messages from './description.i18n'
export const MIN_DESCRIPTION_CHARS = 500
export const MAX_HEADER_LENGTH = 100
export const MIN_SUMMARY_CHARS = 125
function analyzeHeaderLength(markdown: string): { hasLongHeaders: boolean; longHeaders: string[] } {
if (!markdown) return { hasLongHeaders: false, longHeaders: [] }
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const headerRegex = /^(#{1,3})\s+(.+)$/gm
const headers = [...withoutCodeBlocks.matchAll(headerRegex)]
const longHeaders: string[] = []
headers.forEach((match) => {
const headerText = match[2].trim()
const sentenceEnders = /[.!?]+/g
const sentences = headerText.split(sentenceEnders).filter((s) => s.trim().length > 0)
const hasSentenceEnders = sentenceEnders.test(headerText)
const isVeryLong = headerText.length > MAX_HEADER_LENGTH
const hasMultipleSentences = sentences.length > 1
if (hasSentenceEnders || isVeryLong || hasMultipleSentences) {
longHeaders.push(headerText)
}
})
return {
hasLongHeaders: longHeaders.length > 0,
longHeaders,
}
}
function analyzeImageContent(markdown: string): { imageHeavy: boolean; hasEmptyAltText: boolean } {
if (!markdown) return { imageHeavy: false, hasEmptyAltText: false }
const withoutCodeBlocks = markdown.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '')
const imageRegex = /!\[([^\]]*)\]\([^)]+\)/g
const images = [...withoutCodeBlocks.matchAll(imageRegex)]
const htmlImageRegex = /<img[^>]*>/gi
const htmlImages = [...withoutCodeBlocks.matchAll(htmlImageRegex)]
const totalImages = images.length + htmlImages.length
if (totalImages === 0) return { imageHeavy: false, hasEmptyAltText: false }
const textWithoutImages = withoutCodeBlocks
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '')
.replace(/<img[^>]*>/gi, '')
.replace(/\s+/g, ' ')
.trim()
const textLength = textWithoutImages.length
const imageHeavy = textLength < 100 || (totalImages >= 3 && textLength < 200)
const hasEmptyAltText =
images.some((match) => !match[1]?.trim()) ||
htmlImages.some((match) => {
const altMatch = match[0].match(/alt\s*=\s*["']([^"']*)["']/i)
return !altMatch || !altMatch[1]?.trim()
})
return { imageHeavy, hasEmptyAltText }
}
export const descriptionNags: Nag[] = [
{
id: 'description-too-short',
title: messages.descriptionTooShortTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(messages.descriptionTooShortDescription, {
length: context.project.body?.length || 0,
minChars: MIN_DESCRIPTION_CHARS,
})
},
status: 'warning',
shouldShow: (context: NagContext) => {
const bodyLength = context.project.body?.trim()?.length || 0
return bodyLength < MIN_DESCRIPTION_CHARS && bodyLength !== 0
},
link: {
path: 'settings/description',
title: messages.editDescriptionTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'long-headers',
title: messages.longHeadersTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const { longHeaders } = analyzeHeaderLength(context.project.body || '')
const count = longHeaders.length
return formatMessage(messages.longHeadersDescription, {
count,
})
},
status: 'warning',
shouldShow: (context: NagContext) => {
const { hasLongHeaders } = analyzeHeaderLength(context.project.body || '')
return hasLongHeaders
},
link: {
path: 'settings/description',
title: messages.editDescriptionTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'summary-too-short',
title: messages.summaryTooShortTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(messages.summaryTooShortDescription, {
length: context.project.description?.length || 0,
minChars: MIN_SUMMARY_CHARS,
})
},
status: 'warning',
shouldShow: (context: NagContext) => {
const summaryLength = context.project.description?.trim()?.length || 0
return summaryLength < MIN_SUMMARY_CHARS && summaryLength !== 0
},
link: {
path: 'settings',
title: messages.editSummaryTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'minecraft-title-clause',
title: messages.minecraftTitleClauseTitle,
description: messages.minecraftTitleClauseDescription,
status: 'required',
shouldShow: (context: NagContext) => {
const title = context.project.title?.toLowerCase() || ''
const wordsInTitle = title.split(' ').filter((word) => word.length > 0)
return title.includes('minecraft') && title.length > 0 && wordsInTitle.length <= 3
},
link: {
path: 'settings',
title: messages.editTitleTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'title-contains-technical-info',
title: messages.titleContainsTechnicalInfoTitle,
description: messages.titleContainsTechnicalInfoDescription,
status: 'warning',
shouldShow: (context: NagContext) => {
const title = context.project.title?.toLowerCase() || ''
if (!title) return false
const loaderNames =
context.tags.loaders?.map((loader: { name: string }) => loader.name?.toLowerCase()) || []
const hasLoader = loaderNames.some((loader) => loader && title.includes(loader.toLowerCase()))
const versionPatterns = [/\b1\.\d+(\.\d+)?\b/]
const hasVersionPattern = versionPatterns.some((pattern) => pattern.test(title))
return hasLoader || hasVersionPattern
},
link: {
path: 'settings',
title: messages.editTitleTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'summary-same-as-title',
title: messages.summarySameAsTitleTitle,
description: messages.summarySameAsTitleDescription,
status: 'required',
shouldShow: (context: NagContext) => {
const title = context.project.title?.trim() || ''
const summary = context.project.description?.trim() || ''
return title === summary && title.length > 0 && summary.length > 0
},
link: {
path: 'settings',
title: messages.editSummaryTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'image-heavy-description',
title: messages.imageHeavyDescriptionTitle,
description: messages.imageHeavyDescriptionDescription,
status: 'warning',
shouldShow: (context: NagContext) => {
const { imageHeavy } = analyzeImageContent(context.project.body || '')
return imageHeavy
},
link: {
path: 'settings/description',
title: messages.editDescriptionTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
{
id: 'missing-alt-text',
title: messages.missingAltTextTitle,
description: messages.missingAltTextDescription,
status: 'warning',
shouldShow: (context: NagContext) => {
const { hasEmptyAltText } = analyzeImageContent(context.project.body || '')
return hasEmptyAltText
},
link: {
path: 'settings/description',
title: messages.editDescriptionTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description',
},
},
]

View File

@@ -1,4 +0,0 @@
export * from './core'
export * from './links'
export * from './description'
export * from './tags'

View File

@@ -1,48 +0,0 @@
import { defineMessages } from '@vintl/vintl'
export default defineMessages({
verifyExternalLinksTitle: {
id: 'nags.verify-external-links.title',
defaultMessage: 'Verify external links',
},
verifyExternalLinksDescription: {
id: 'nags.verify-external-links.description',
defaultMessage:
"Some of your external links may be using domains that aren't recognized as common for their link type.",
},
invalidLicenseUrlTitle: {
id: 'nags.invalid-license-url.title',
defaultMessage: 'Invalid license URL',
},
invalidLicenseUrlDescriptionDefault: {
id: 'nags.invalid-license-url.description.default',
defaultMessage: 'License URL is invalid.',
},
invalidLicenseUrlDescriptionDomain: {
id: 'nags.invalid-license-url.description.domain',
defaultMessage:
'Your license URL points to {domain}, which is not appropriate for license information. License URLs should link to the actual license text or legal documentation, not social media, gaming platforms etc.',
},
invalidLicenseUrlDescriptionMalformed: {
id: 'nags.invalid-license-url.description.malformed',
defaultMessage:
'Your license URL appears to be malformed. Please provide a valid URL to your license text.',
},
gplLicenseSourceRequiredTitle: {
id: 'nags.gpl-license-source-required.title',
defaultMessage: 'GPL license requires source',
},
gplLicenseSourceRequiredDescription: {
id: 'nags.gpl-license-source-required.description',
defaultMessage:
'Your {projectType} uses a GPL license which requires source code to be available. Please provide a source code link or consider using a different license.',
},
visitLinksSettingsTitle: {
id: 'nags.visit-links-settings.title',
defaultMessage: 'Visit links settings',
},
editLicenseTitle: {
id: 'nags.edit-license.title',
defaultMessage: 'Edit license',
},
})

View File

@@ -1,155 +0,0 @@
import type { Nag, NagContext } from '../../types/nags'
import { formatProjectType } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import messages from './links.i18n'
export const commonLinkDomains = {
source: ['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org', 'git.sr.ht'],
issues: ['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org'],
discord: ['discord.gg', 'discord.com'],
licenseBlocklist: [
'youtube.com',
'youtu.be',
'modrinth.com',
'curseforge.com',
'twitter.com',
'x.com',
'discord.gg',
'discord.com',
'instagram.com',
'facebook.com',
'tiktok.com',
'reddit.com',
'twitch.tv',
'patreon.com',
'ko-fi.com',
'paypal.com',
'buymeacoffee.com',
],
}
export function isCommonUrl(url: string | undefined, commonDomains: string[]): boolean {
if (!url) return false
try {
const domain = new URL(url).hostname.toLowerCase()
return commonDomains.some((allowed) => domain.includes(allowed))
} catch {
return false
}
}
export function isUncommonLicenseUrl(url: string | undefined, domains: string[]): boolean {
if (!url) return false
try {
const domain = new URL(url).hostname.toLowerCase()
return domains.some((uncommonDomain) => domain.includes(uncommonDomain))
} catch {
return false
}
}
export const linksNags: Nag[] = [
{
id: 'verify-external-links',
title: messages.verifyExternalLinksTitle,
description: messages.verifyExternalLinksDescription,
status: 'warning',
shouldShow: (context: NagContext) => {
return (
!isCommonUrl(context.project.source_url, commonLinkDomains.source) ||
!isCommonUrl(context.project.issues_url, commonLinkDomains.issues) ||
!isCommonUrl(context.project.discord_url, commonLinkDomains.discord)
)
},
link: {
path: 'settings/links',
title: messages.visitLinksSettingsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
{
id: 'invalid-license-url',
title: messages.invalidLicenseUrlTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const licenseUrl = context.project.license.url
if (!licenseUrl) {
return formatMessage(messages.invalidLicenseUrlDescriptionDefault)
}
try {
const domain = new URL(licenseUrl).hostname.toLowerCase()
return formatMessage(messages.invalidLicenseUrlDescriptionDomain, { domain })
} catch {
return formatMessage(messages.invalidLicenseUrlDescriptionMalformed)
}
},
status: 'required',
shouldShow: (context: NagContext) => {
const licenseUrl = context.project.license.url
if (!licenseUrl) return false
const isBlocklisted = isUncommonLicenseUrl(licenseUrl, commonLinkDomains.licenseBlocklist)
try {
new URL(licenseUrl)
return isBlocklisted
} catch {
return true
}
},
link: {
path: 'settings',
title: messages.editLicenseTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings',
},
},
{
id: 'gpl-license-source-required',
title: messages.gplLicenseSourceRequiredTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(messages.gplLicenseSourceRequiredDescription, {
projectType: formatProjectType(context.project.project_type).toLowerCase(),
})
},
status: 'required',
shouldShow: (context: NagContext) => {
const gplLicenses = [
'GPL-2.0',
'GPL-2.0+',
'GPL-2.0-only',
'GPL-2.0-or-later',
'GPL-3.0',
'GPL-3.0+',
'GPL-3.0-only',
'GPL-3.0-or-later',
'LGPL-2.1',
'LGPL-2.1+',
'LGPL-2.1-only',
'LGPL-2.1-or-later',
'LGPL-3.0',
'LGPL-3.0+',
'LGPL-3.0-only',
'LGPL-3.0-or-later',
'AGPL-3.0',
'AGPL-3.0+',
'AGPL-3.0-only',
'AGPL-3.0-or-later',
]
const isGplLicense = gplLicenses.includes(context.project.license.id)
const hasSourceUrl = !!context.project.source_url
return isGplLicense && !hasSourceUrl
},
link: {
path: 'settings/links',
title: messages.visitLinksSettingsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
]

View File

@@ -1,35 +0,0 @@
import { defineMessages } from '@vintl/vintl'
export default defineMessages({
tooManyTagsTitle: {
id: 'nags.too-many-tags.title',
defaultMessage: 'Too many tags selected',
},
tooManyTagsDescription: {
id: 'nags.too-many-tags.description',
defaultMessage:
"You've selected {tagCount} tags. Consider reducing to 5 or fewer to keep your project focused and easier to discover.",
},
multipleResolutionTagsTitle: {
id: 'nags.multiple-resolution-tags.title',
defaultMessage: 'Multiple resolution tags selected',
},
multipleResolutionTagsDescription: {
id: 'nags.multiple-resolution-tags.description',
defaultMessage:
"You've selected {count} resolution tags ({tags}). Resource packs should typically only have one resolution tag that matches their primary resolution.",
},
allTagsSelectedTitle: {
id: 'nags.all-tags-selected.title',
defaultMessage: 'All tags selected',
},
allTagsSelectedDescription: {
id: 'nags.all-tags-selected.description',
defaultMessage:
"You've selected all {totalAvailableTags} available tags. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that truly apply to your project.",
},
editTagsTitle: {
id: 'nags.edit-tags.title',
defaultMessage: 'Edit tags',
},
})

View File

@@ -1,107 +0,0 @@
import type { Project } from '@modrinth/utils'
import type { Nag, NagContext } from '../../types/nags'
import { useVIntl } from '@vintl/vintl'
import messages from './tags.i18n'
function getCategories(
project: Project & { actualProjectType: string },
tags: {
categories?: {
project_type: string
}[]
},
) {
return (
tags.categories?.filter(
(category: { project_type: string }) => category.project_type === project.actualProjectType,
) ?? []
)
}
export const tagsNags: Nag[] = [
{
id: 'too-many-tags',
title: messages.tooManyTagsTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const tagCount =
context.project.categories.length + (context.project.additional_categories?.length || 0)
return formatMessage(messages.tooManyTagsDescription, {
tagCount,
})
},
status: 'warning',
shouldShow: (context: NagContext) => {
const tagCount =
context.project.categories.length + (context.project.additional_categories?.length || 0)
return tagCount > 5
},
link: {
path: 'settings/tags',
title: messages.editTagsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
},
},
{
id: 'multiple-resolution-tags',
title: messages.multipleResolutionTagsTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const resolutionTags = context.project.categories.filter((tag: string) =>
['16x', '32x', '48x', '64x', '128x', '256x', '512x', '1024x'].includes(tag),
)
return formatMessage(messages.multipleResolutionTagsDescription, {
count: resolutionTags.length,
tags: resolutionTags.join(', '),
})
},
status: 'warning',
shouldShow: (context: NagContext) => {
if (context.project.project_type !== 'resourcepack') return false
const resolutionTags = context.project.categories.filter((tag: string) =>
['16x', '32x', '48x', '64x', '128x', '256x', '512x', '1024x'].includes(tag),
)
return resolutionTags.length > 1
},
link: {
path: 'settings/tags',
title: messages.editTagsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
},
},
{
id: 'all-tags-selected',
title: messages.allTagsSelectedTitle,
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const categoriesForProjectType = getCategories(
context.project as Project & { actualProjectType: string },
context.tags,
)
const totalAvailableTags = categoriesForProjectType.length
return formatMessage(messages.allTagsSelectedDescription, {
totalAvailableTags,
})
},
status: 'required',
shouldShow: (context: NagContext) => {
const categoriesForProjectType = getCategories(
context.project as Project & { actualProjectType: string },
context.tags,
)
const totalSelectedTags =
context.project.categories.length + (context.project.additional_categories?.length || 0)
return totalSelectedTags === categoriesForProjectType.length
},
link: {
path: 'settings/tags',
title: messages.editTagsTitle,
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags',
},
},
]

View File

@@ -2,10 +2,7 @@ export * from './types/actions'
export * from './types/messages'
export * from './types/stage'
export * from './types/keybinds'
export * from './types/nags'
export * from './utils'
export * from './data/nags/index'
export { default as checklist } from './data/checklist'
export { default as keybinds } from './data/keybinds'
export { default as nags } from './data/nags'

View File

@@ -1,191 +0,0 @@
{
"nags.add-description.description": {
"defaultMessage": "A description that clearly describes the project's purpose and function is required."
},
"nags.add-description.title": {
"defaultMessage": "Add a description"
},
"nags.add-icon.description": {
"defaultMessage": "Your project should have a nice-looking icon to uniquely identify your project at a glance."
},
"nags.add-icon.title": {
"defaultMessage": "Add an icon"
},
"nags.add-links.description": {
"defaultMessage": "Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite."
},
"nags.add-links.title": {
"defaultMessage": "Add external links"
},
"nags.all-tags-selected.description": {
"defaultMessage": "You've selected all {totalAvailableTags} available tags. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that truly apply to your project."
},
"nags.all-tags-selected.title": {
"defaultMessage": "All tags selected"
},
"nags.description-too-short.description": {
"defaultMessage": "Your description is {length} characters. It's recommended to have at least {minChars} characters to provide users with enough information about your project."
},
"nags.description-too-short.title": {
"defaultMessage": "Description may be insufficient"
},
"nags.edit-description.title": {
"defaultMessage": "Edit description"
},
"nags.edit-license.title": {
"defaultMessage": "Edit license"
},
"nags.edit-summary.title": {
"defaultMessage": "Edit summary"
},
"nags.edit-tags.title": {
"defaultMessage": "Edit tags"
},
"nags.edit-title.title": {
"defaultMessage": "Edit title"
},
"nags.feature-gallery-image.description": {
"defaultMessage": "Featured gallery images may be the first impression of many users."
},
"nags.feature-gallery-image.title": {
"defaultMessage": "Feature a gallery image"
},
"nags.gallery.title": {
"defaultMessage": "Visit gallery page"
},
"nags.gpl-license-source-required.description": {
"defaultMessage": "Your {projectType} uses a GPL license which requires source code to be available. Please provide a source code link or consider using a different license."
},
"nags.gpl-license-source-required.title": {
"defaultMessage": "GPL license requires source"
},
"nags.image-heavy-description.description": {
"defaultMessage": "Please add more descriptive text to help users understand your project, especially those using screen readers or with slow internet connections."
},
"nags.image-heavy-description.title": {
"defaultMessage": "Description is mostly images"
},
"nags.invalid-license-url.description.default": {
"defaultMessage": "License URL is invalid."
},
"nags.invalid-license-url.description.domain": {
"defaultMessage": "Your license URL points to {domain}, which is not appropriate for license information. License URLs should link to the actual license text or legal documentation, not social media, gaming platforms etc."
},
"nags.invalid-license-url.description.malformed": {
"defaultMessage": "Your license URL appears to be malformed. Please provide a valid URL to your license text."
},
"nags.invalid-license-url.title": {
"defaultMessage": "Invalid license URL"
},
"nags.long-headers.description": {
"defaultMessage": "{count, plural, one {# header} other {# headers}} in your description {count, plural, one {is} other {are}} too long. Headers should be concise and act as section titles, not full sentences."
},
"nags.long-headers.title": {
"defaultMessage": "Headers are too long"
},
"nags.minecraft-title-clause.description": {
"defaultMessage": "Please remove \"Minecraft\" from your title. You cannot use \"Minecraft\" in your title for legal reasons."
},
"nags.minecraft-title-clause.title": {
"defaultMessage": "Title contains \"Minecraft\""
},
"nags.missing-alt-text.description": {
"defaultMessage": "Some of your images are missing alt text, which is important for accessibility, especially for visually impaired users."
},
"nags.missing-alt-text.title": {
"defaultMessage": "Images missing alt text"
},
"nags.moderation.title": {
"defaultMessage": "Visit moderation thread"
},
"nags.moderator-feedback.description": {
"defaultMessage": "Review any feedback from moderators regarding your project before resubmitting."
},
"nags.moderator-feedback.title": {
"defaultMessage": "Review moderator feedback"
},
"nags.multiple-resolution-tags.description": {
"defaultMessage": "You've selected {count} resolution tags ({tags}). Resource packs should typically only have one resolution tag that matches their primary resolution."
},
"nags.multiple-resolution-tags.title": {
"defaultMessage": "Multiple resolution tags selected"
},
"nags.select-environments.description": {
"defaultMessage": "Select if the {projectType} functions on the client-side and/or server-side."
},
"nags.select-environments.title": {
"defaultMessage": "Select supported environments"
},
"nags.select-license.description": {
"defaultMessage": "Select the license your {projectType} is distributed under."
},
"nags.select-license.title": {
"defaultMessage": "Select license"
},
"nags.select-tags.description": {
"defaultMessage": "Select all tags that apply to your project."
},
"nags.select-tags.title": {
"defaultMessage": "Select tags"
},
"nags.settings.description.title": {
"defaultMessage": "Visit description settings"
},
"nags.settings.environments.title": {
"defaultMessage": "Visit general settings"
},
"nags.settings.license.title": {
"defaultMessage": "Visit license settings"
},
"nags.settings.links.title": {
"defaultMessage": "Visit links settings"
},
"nags.settings.tags.title": {
"defaultMessage": "Visit tag settings"
},
"nags.settings.title": {
"defaultMessage": "Visit general settings"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Your summary is the same as your project name. Please change it. It's recommended to have a unique summary to provide more context about your project."
},
"nags.summary-same-as-title.title": {
"defaultMessage": "Summary is project name"
},
"nags.summary-too-short.description": {
"defaultMessage": "Your summary is {length} characters. It's recommended to have at least {minChars} characters to provide users with enough information about your project."
},
"nags.summary-too-short.title": {
"defaultMessage": "Summary may be insufficient"
},
"nags.title-contains-technical-info.description": {
"defaultMessage": "Removing these helps keep titles clean and makes your project easier to find. Version and loader information is automatically displayed alongside your project."
},
"nags.title-contains-technical-info.title": {
"defaultMessage": "Title contains loader or version info"
},
"nags.too-many-tags.description": {
"defaultMessage": "You've selected {tagCount} tags. Consider reducing to 5 or fewer to keep your project focused and easier to discover."
},
"nags.too-many-tags.title": {
"defaultMessage": "Too many tags selected"
},
"nags.upload-version.description": {
"defaultMessage": "At least one version is required for a project to be submitted for review."
},
"nags.upload-version.title": {
"defaultMessage": "Upload a version"
},
"nags.verify-external-links.description": {
"defaultMessage": "Some of your external links may be using domains that aren't recognized as common for their link type."
},
"nags.verify-external-links.title": {
"defaultMessage": "Verify external links"
},
"nags.versions.title": {
"defaultMessage": "Visit versions page"
},
"nags.visit-links-settings.title": {
"defaultMessage": "Visit links settings"
}
}

View File

@@ -6,17 +6,14 @@
"types": "./index.d.ts",
"scripts": {
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write . && pnpm run intl:extract",
"intl:extract": "formatjs extract \"**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore \"node_modules/**/*\" --out-file locales/en-US/index.json --preserve-whitespace"
"fix": "eslint . --fix && prettier --write ."
},
"dependencies": {
"@modrinth/assets": "workspace:*",
"@modrinth/utils": "workspace:*",
"@modrinth/assets": "workspace:*",
"vue": "^3.5.13"
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@vintl/vintl": "^4.4.1",
"eslint": "^8.57.0",
"eslint-config-custom": "workspace:*",
"tsconfig": "workspace:*"

View File

@@ -1,96 +0,0 @@
import type { Project, User, Version } from '@modrinth/utils'
import type { MessageDescriptor } from '@vintl/vintl'
import type { FunctionalComponent, SVGAttributes } from 'vue'
/**
* Type which represents the status type of a nag.
*
* - `required` indicates that the nag must be addressed.
* - `warning` indicates that the nag is important but not critical, and can be ignored. It is often used for issues that should be resolved but do not block project submission.
* - `suggestion` indicates that the nag is a recommendation and can be ignored.
*/
export type NagStatus = 'required' | 'warning' | 'suggestion' | 'special-submit-action'
/**
* Interface representing the context in which a nag is displayed.
* It includes the project, versions, current member, all members, and the current route.
* This context is used to determine whether a nag or it's link should be shown and how it should be presented.
*/
export interface NagContext {
/**
* The project associated with the nag.
*/
project: Project
/**
* The versions associated with the project.
*/
versions: Version[]
/**
* The current project member viewing the nag.
*/
currentMember: User
/**
* The current route in the application.
*/
currentRoute: string
/* eslint-disable @typescript-eslint/no-explicit-any */
tags: any
submitProject: (...any: any) => any
/* eslint-enable @typescript-eslint/no-explicit-any */
}
/**
* Interface representing a nag's link.
*/
export interface NagLink {
/**
* A relative path to the nag's link, e.g. '/settings'.
*/
path: string
/**
* The text to display for the nag's link.
*/
title: MessageDescriptor | string
/**
* The status of the nag, which can be 'required', 'warning', or 'suggestion'.
*/
shouldShow?: (context: NagContext) => boolean
}
/**
* Interface representing a nag.
*/
export interface Nag {
/**
* A unique identifier for the nag.
*/
id: string
/**
* The title of the nag.
*/
title: MessageDescriptor | string
/**
* A function that returns the description of the nag.
* It can accept a context to provide dynamic descriptions.
*/
description: MessageDescriptor | ((context: NagContext) => string)
/**
* The status of the nag, which can be 'required', 'warning', or 'suggestion'.
*/
status: NagStatus
/**
* An optional icon for the nag, usually from `@modrinth/assets`.
* If not specified it will use the default icon associated with the nag status.
*/
icon?: FunctionalComponent<SVGAttributes>
/**
* A function that determines whether the nag should be shown based on the context.
*/
shouldShow: (context: NagContext) => boolean
/**
* An optional link associated with the nag.
* If provided, it should be displayed alongside the nag.
*/
link?: NagLink
}

View File

@@ -0,0 +1,140 @@
<template>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="isVisible" class="w-full">
<div class="mb-2 flex justify-between text-sm">
<Transition name="phrase-fade" mode="out-in">
<span :key="currentPhrase" class="text-md font-semibold">{{ currentPhrase }}</span>
</Transition>
<div class="flex flex-col items-end">
<span class="text-secondary">{{ Math.round(progress) }}%</span>
<span class="text-xs text-secondary"
>{{ formatBytes(currentValue) }} / {{ formatBytes(maxValue) }}</span
>
</div>
</div>
<div class="h-2 w-full rounded-full bg-divider">
<div
class="h-2 animate-pulse bg-brand rounded-full transition-all duration-300 ease-out"
:style="{ width: `${progress}%` }"
></div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { formatBytes } from '@modrinth/utils'
import { computed, onUnmounted, ref, watch } from 'vue'
interface Props {
maxValue: number
currentValue: number
tips?: string[]
}
const props = withDefaults(defineProps<Props>(), {
tips: () => [
'Removing Herobrine...',
'Feeding parrots...',
'Teaching villagers new trades...',
'Convincing creepers to be friendly...',
'Polishing diamonds...',
'Training wolves to fetch...',
'Building pixel art...',
'Explaining redstone to beginners...',
'Collecting all the cats...',
'Negotiating with endermen...',
'Planting suspicious stew ingredients...',
'Calibrating TNT blast radius...',
'Teaching chickens to fly...',
'Sorting inventory alphabetically...',
'Convincing iron golems to smile...',
],
})
const currentPhrase = ref('')
const usedPhrases = ref(new Set<number>())
let phraseInterval: NodeJS.Timeout | null = null
const progress = computed(() => {
if (props.maxValue === 0) return 0
return Math.min((props.currentValue / props.maxValue) * 100, 100)
})
const isVisible = computed(() => props.maxValue > 0 && props.currentValue >= 0)
function getNextPhrase() {
if (usedPhrases.value.size >= props.tips.length) {
const currentPhraseIndex = props.tips.indexOf(currentPhrase.value)
usedPhrases.value.clear()
if (currentPhraseIndex !== -1) {
usedPhrases.value.add(currentPhraseIndex)
}
}
const availableIndices = props.tips
.map((_, index) => index)
.filter((index) => !usedPhrases.value.has(index))
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)]
usedPhrases.value.add(randomIndex)
return props.tips[randomIndex]
}
function startPhraseRotation() {
if (phraseInterval) {
clearInterval(phraseInterval)
}
currentPhrase.value = getNextPhrase()
phraseInterval = setInterval(() => {
currentPhrase.value = getNextPhrase()
}, 4500)
}
function stopPhraseRotation() {
if (phraseInterval) {
clearInterval(phraseInterval)
phraseInterval = null
}
}
watch(isVisible, (newVisible) => {
if (newVisible) {
startPhraseRotation()
} else {
stopPhraseRotation()
usedPhrases.value.clear()
}
})
watch(progress, (newProgress) => {
if (newProgress >= 100) {
stopPhraseRotation()
currentPhrase.value = 'Installing modpack...'
}
})
onUnmounted(() => {
stopPhraseRotation()
})
</script>
<style scoped>
.phrase-fade-enter-active,
.phrase-fade-leave-active {
transition: opacity 0.3s ease;
}
.phrase-fade-enter-from,
.phrase-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<div class="joined-buttons">
<ButtonStyled :color="color">
<button :disabled="disabled" @click="handlePrimaryAction">
<component :is="primaryAction.icon" v-if="primaryAction.icon" aria-hidden="true" />
{{ primaryAction.label }}
</button>
</ButtonStyled>
<ButtonStyled v-if="dropdownActions.length > 0" :color="color">
<OverflowMenu class="btn-dropdown-animation" :options="dropdownOptions" :disabled="disabled">
<DropdownIcon />
<template v-for="action in dropdownActions" :key="action.id" #[action.id]>
<component :is="action.icon" v-if="action.icon" aria-hidden="true" />
{{ action.label }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ButtonStyled, OverflowMenu } from '../index'
import { DropdownIcon } from '@modrinth/assets'
import type { Component } from 'vue'
// TODO: This should be moved to a shared types file.
type Colors = 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
export interface JoinedButtonAction {
id: string
label: string
icon?: Component
action: () => void
color?: Colors
hoverFilled?: boolean
}
interface Props {
actions: JoinedButtonAction[]
color?: Colors
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
color: 'standard',
disabled: false,
})
const primaryAction = computed(() => props.actions[0])
const dropdownActions = computed(() => props.actions.slice(1))
const colorMap: Record<
Colors,
| 'red'
| 'orange'
| 'green'
| 'blue'
| 'purple'
| 'highlight'
| 'primary'
| 'danger'
| 'secondary'
| undefined
> = {
standard: 'secondary',
brand: 'primary',
red: 'red',
orange: 'orange',
green: 'green',
blue: 'blue',
purple: 'purple',
}
const dropdownOptions = computed(() =>
dropdownActions.value.map((action) => ({
id: action.id,
color: action.color ? colorMap[action.color] : undefined,
action: action.action,
hoverFilled: action.hoverFilled ?? true,
})),
)
function handlePrimaryAction() {
if (primaryAction.value && !props.disabled) {
primaryAction.value.action()
}
}
</script>
<style scoped>
.joined-buttons {
display: flex;
align-items: center;
}
.joined-buttons > :deep(.btn) {
border-radius: 0;
}
.joined-buttons > :deep(.btn:first-child) {
border-top-left-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.joined-buttons > :deep(.btn:last-child) {
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
margin-left: -1px;
}
.joined-buttons > :deep(.btn:not(:last-child)) {
border-right: none;
}
.btn-dropdown-animation {
padding: 0.5rem !important;
}
</style>

View File

@@ -9,7 +9,11 @@
@mouseenter="stopTimer(item)"
@mouseleave="setNotificationTimer(item)"
>
<div class="vue-notification-template vue-notification" :class="{ [item.type]: true }">
<div
class="vue-notification-template vue-notification"
:class="{ [item.type]: true }"
@click="item.clickAction"
>
<div class="notification-title" v-html="item.title"></div>
<div class="notification-content" v-html="item.text"></div>
</div>
@@ -31,11 +35,14 @@ const notifications = ref([])
defineExpose({
addNotification: (notification) => {
notification.clickAction = notification.clickAction ?? (() => {})
const existingNotif = notifications.value.find(
(x) =>
x.text === notification.text &&
x.title === notification.title &&
x.type === notification.type,
x.type === notification.type &&
x.clickAction === notification.clickAction,
)
if (existingNotif) {
setNotificationTimer(existingNotif)

View File

@@ -1,6 +1,7 @@
// Base content
export { default as Accordion } from './base/Accordion.vue'
export { default as Admonition } from './base/Admonition.vue'
export { default as AppearingProgressBar } from './base/AppearingProgressBar.vue'
export { default as AutoLink } from './base/AutoLink.vue'
export { default as Avatar } from './base/Avatar.vue'
export { default as Badge } from './base/Badge.vue'
@@ -21,6 +22,8 @@ export { default as FileInput } from './base/FileInput.vue'
export { default as FilterBar } from './base/FilterBar.vue'
export type { FilterBarOption } from './base/FilterBar.vue'
export { default as HeadingLink } from './base/HeadingLink.vue'
export { default as JoinedButtons } from './base/JoinedButtons.vue'
export type { JoinedButtonAction } from './base/JoinedButtons.vue'
export { default as LoadingIndicator } from './base/LoadingIndicator.vue'
export { default as ManySelect } from './base/ManySelect.vue'
export { default as MarkdownEditor } from './base/MarkdownEditor.vue'

View File

@@ -22,6 +22,7 @@
<div class="modal-body flex flex-col bg-bg-raised rounded-2xl">
<div
data-tauri-drag-region
v-if="!hideHeader"
class="grid grid-cols-[auto_min-content] items-center gap-12 p-6 border-solid border-0 border-b-[1px] border-divider max-w-full"
>
<div class="flex text-wrap break-words items-center gap-3 min-w-0">
@@ -60,6 +61,7 @@ const props = withDefaults(
closeOnClickOutside?: boolean
warnOnClose?: boolean
header?: string
hideHeader?: boolean
onHide?: () => void
onShow?: () => void
}>(),
@@ -71,6 +73,7 @@ const props = withDefaults(
closeOnEsc: true,
warnOnClose: false,
header: undefined,
hideHeader: false,
onHide: () => {},
onShow: () => {},
},
@@ -134,7 +137,7 @@ function updateMousePosition(event: { clientX: number; clientY: number }) {
}
function handleKeyDown(event: KeyboardEvent) {
if (props.closeOnEsc && event.key === 'Escape') {
if (props.closeOnEsc && event.key === 'Escape' && props.closable) {
hide()
mouseX.value = window.innerWidth / 2
mouseY.value = window.innerHeight / 2

View File

@@ -10,6 +10,13 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2025-07-19T15:20:00-07:00`,
product: 'web',
body: `### Improvements
- Removed Tumblr icon from footer as we no longer use it.
- Reverted changes to publishing checklist since they need more work.`,
},
{
date: `2025-07-16T12:40:00-07:00`,
product: 'web',

178
pnpm-lock.yaml generated
View File

@@ -473,12 +473,6 @@ importers:
specifier: ^3.5.13
version: 3.5.13(typescript@5.8.3)
devDependencies:
'@formatjs/cli':
specifier: ^6.2.12
version: 6.2.12(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.8.3))
'@vintl/vintl':
specifier: ^4.4.1
version: 4.4.1(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))
eslint:
specifier: ^8.57.0
version: 8.57.0
@@ -587,7 +581,7 @@ importers:
version: 7.3.1
'@vintl/unplugin':
specifier: ^1.5.1
version: 1.5.2(@vue/compiler-core@3.5.13)(rollup@3.29.4)(vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.43.1))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)
version: 1.5.2(@vue/compiler-core@3.5.13)(rollup@3.29.4)(vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)
'@vintl/vintl':
specifier: ^4.4.1
version: 4.4.1(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
@@ -1850,9 +1844,6 @@ packages:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@jridgewell/gen-mapping@0.3.12':
resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
'@jridgewell/gen-mapping@0.3.5':
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
engines: {node: '>=6.0.0'}
@@ -1865,24 +1856,15 @@ packages:
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
'@jridgewell/source-map@0.3.10':
resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==}
'@jridgewell/source-map@0.3.6':
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
'@jridgewell/sourcemap-codec@1.5.4':
resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@jridgewell/trace-mapping@0.3.29':
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
@@ -3518,8 +3500,8 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
browserslist@4.25.1:
resolution: {integrity: sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==}
browserslist@4.25.0:
resolution: {integrity: sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
@@ -3552,8 +3534,8 @@ packages:
magicast:
optional: true
c12@3.1.0:
resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==}
c12@3.0.4:
resolution: {integrity: sha512-t5FaZTYbbCtvxuZq9xxIruYydrAGsJ+8UdP0pZzMiK2xl/gNiSOy0OxhLzHUEEb0m1QXYqfzfvyIFEmz/g9lqg==}
peerDependencies:
magicast: ^0.3.5
peerDependenciesMeta:
@@ -3605,8 +3587,8 @@ packages:
caniuse-lite@1.0.30001687:
resolution: {integrity: sha512-0S/FDhf4ZiqrTUiQ39dKeUjYRjkv7lOZU1Dgif2rIqrTzX/1wV2hfKu9TOm1IHkdSijfLswxTFzl/cvir+SLSQ==}
caniuse-lite@1.0.30001727:
resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==}
caniuse-lite@1.0.30001723:
resolution: {integrity: sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -4169,8 +4151,8 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
electron-to-chromium@1.5.182:
resolution: {integrity: sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==}
electron-to-chromium@1.5.167:
resolution: {integrity: sha512-LxcRvnYO5ez2bMOFpbuuVuAI5QNeY1ncVytE/KXaL6ZNfzX1yPlAO0nSOyIHx2fVAuUprMqPs/TdVhUFZy7SIQ==}
electron-to-chromium@1.5.71:
resolution: {integrity: sha512-dB68l59BI75W1BUGVTAEJy45CEVuEGy9qPVVQ8pnHyHMn36PLPPoE1mjLH+lo9rKulO3HC2OhbACI/8tCqJBcA==}
@@ -4205,8 +4187,8 @@ packages:
resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==}
engines: {node: '>=10.13.0'}
enhanced-resolve@5.18.2:
resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==}
enhanced-resolve@5.18.1:
resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
engines: {node: '>=10.13.0'}
entities@2.2.0:
@@ -6406,8 +6388,8 @@ packages:
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
pkg-types@2.2.0:
resolution: {integrity: sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==}
pkg-types@2.1.1:
resolution: {integrity: sha512-eY0QFb6eSwc9+0d/5D2lFFUq+A3n3QNGSy/X2Nvp+6MfzGw2u6EbA7S80actgjY1lkvvI0pqB+a4hioMh443Ew==}
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
@@ -6624,8 +6606,8 @@ packages:
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
postcss@8.5.5:
resolution: {integrity: sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==}
engines: {node: ^10 || ^12 || >=14}
posthog-js@1.158.2:
@@ -7509,11 +7491,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
terser@5.43.1:
resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==}
engines: {node: '>=10'}
hasBin: true
text-decoder@1.1.0:
resolution: {integrity: sha512-TmLJNj6UgX8xcUZo4UDStGQtDiTzF7BzWlzn9g7UWrjkpHr5uJTK1ld16wZ3LXb2vb6jH8qU89dW5whuMdXYdw==}
@@ -8376,8 +8353,8 @@ packages:
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
webpack-sources@3.3.3:
resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==}
webpack-sources@3.3.2:
resolution: {integrity: sha512-ykKKus8lqlgXX/1WjudpIEjqsafjOTcOJqxnAbMLAu/KCsDCJ6GBtvscewvTkrn24HsnvFwrSCbenFrhtcCsAA==}
engines: {node: '>=10.13.0'}
webpack-virtual-modules@0.6.2:
@@ -8932,10 +8909,6 @@ snapshots:
dependencies:
vue: 3.5.13(typescript@5.5.4)
'@braw/async-computed@5.0.2(vue@3.5.13(typescript@5.8.3))':
dependencies:
vue: 3.5.13(typescript@5.8.3)
'@cloudflare/kv-asset-handler@0.3.4':
dependencies:
mime: 3.0.0
@@ -9463,11 +9436,6 @@ snapshots:
'@vue/compiler-core': 3.5.13
vue: 3.5.13(typescript@5.5.4)
'@formatjs/cli@6.2.12(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.8.3))':
optionalDependencies:
'@vue/compiler-core': 3.5.13
vue: 3.5.13(typescript@5.8.3)
'@formatjs/ecma402-abstract@1.18.3':
dependencies:
'@formatjs/intl-localematcher': 0.5.4
@@ -9525,18 +9493,6 @@ snapshots:
optionalDependencies:
typescript: 5.5.4
'@formatjs/intl@2.10.4(typescript@5.8.3)':
dependencies:
'@formatjs/ecma402-abstract': 2.0.0
'@formatjs/fast-memoize': 2.2.0
'@formatjs/icu-messageformat-parser': 2.7.8
'@formatjs/intl-displaynames': 6.6.8
'@formatjs/intl-listformat': 7.5.7
intl-messageformat: 10.5.14
tslib: 2.6.3
optionalDependencies:
typescript: 5.8.3
'@formatjs/ts-transformer@3.13.14':
dependencies:
'@formatjs/icu-messageformat-parser': 2.7.8
@@ -9660,12 +9616,6 @@ snapshots:
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@jridgewell/gen-mapping@0.3.12':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.4
'@jridgewell/trace-mapping': 0.3.29
optional: true
'@jridgewell/gen-mapping@0.3.5':
dependencies:
'@jridgewell/set-array': 1.2.1
@@ -9676,12 +9626,6 @@ snapshots:
'@jridgewell/set-array@1.2.1': {}
'@jridgewell/source-map@0.3.10':
dependencies:
'@jridgewell/gen-mapping': 0.3.12
'@jridgewell/trace-mapping': 0.3.29
optional: true
'@jridgewell/source-map@0.3.6':
dependencies:
'@jridgewell/gen-mapping': 0.3.5
@@ -9689,20 +9633,11 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.0': {}
'@jridgewell/sourcemap-codec@1.5.4':
optional: true
'@jridgewell/trace-mapping@0.3.25':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@jridgewell/trace-mapping@0.3.29':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.4
optional: true
'@jsdevtools/ono@7.1.3': {}
'@kwsites/file-exists@1.1.1':
@@ -9950,7 +9885,7 @@ snapshots:
'@nuxt/kit@3.17.5(magicast@0.3.5)':
dependencies:
c12: 3.1.0(magicast@0.3.5)
c12: 3.0.4(magicast@0.3.5)
consola: 3.4.2
defu: 6.1.4
destr: 2.0.5
@@ -9963,7 +9898,7 @@ snapshots:
mlly: 1.7.4
ohash: 2.0.11
pathe: 2.0.3
pkg-types: 2.2.0
pkg-types: 2.1.1
scule: 1.3.0
semver: 7.7.2
std-env: 3.9.0
@@ -11165,7 +11100,7 @@ snapshots:
- vue
- webpack
'@vintl/unplugin@1.5.2(@vue/compiler-core@3.5.13)(rollup@3.29.4)(vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.43.1))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)':
'@vintl/unplugin@1.5.2(@vue/compiler-core@3.5.13)(rollup@3.29.4)(vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)':
dependencies:
'@formatjs/cli-lib': 6.4.2(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.5.4))
'@formatjs/icu-messageformat-parser': 2.7.8
@@ -11176,7 +11111,7 @@ snapshots:
unplugin: 1.16.0
optionalDependencies:
rollup: 3.29.4
vite: 4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.43.1)
vite: 4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0)
webpack: 5.92.1
transitivePeerDependencies:
- '@glimmer/env'
@@ -11224,17 +11159,6 @@ snapshots:
transitivePeerDependencies:
- typescript
'@vintl/vintl@4.4.1(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))':
dependencies:
'@braw/async-computed': 5.0.2(vue@3.5.13(typescript@5.8.3))
'@formatjs/icu-messageformat-parser': 2.7.8
'@formatjs/intl': 2.10.4(typescript@5.8.3)
'@formatjs/intl-localematcher': 0.4.2
intl-messageformat: 10.5.14
vue: 3.5.13(typescript@5.8.3)
transitivePeerDependencies:
- typescript
'@vitejs/plugin-vue-jsx@4.1.1(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))':
dependencies:
'@babel/core': 7.26.0
@@ -12037,12 +11961,12 @@ snapshots:
node-releases: 2.0.18
update-browserslist-db: 1.1.1(browserslist@4.24.2)
browserslist@4.25.1:
browserslist@4.25.0:
dependencies:
caniuse-lite: 1.0.30001727
electron-to-chromium: 1.5.182
caniuse-lite: 1.0.30001723
electron-to-chromium: 1.5.167
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.1)
update-browserslist-db: 1.1.3(browserslist@4.25.0)
optional: true
buffer-crc32@1.0.0: {}
@@ -12081,7 +12005,7 @@ snapshots:
optionalDependencies:
magicast: 0.3.5
c12@3.1.0(magicast@0.3.5):
c12@3.0.4(magicast@0.3.5):
dependencies:
chokidar: 4.0.3
confbox: 0.2.2
@@ -12093,7 +12017,7 @@ snapshots:
ohash: 2.0.11
pathe: 2.0.3
perfect-debounce: 1.0.0
pkg-types: 2.2.0
pkg-types: 2.1.1
rc9: 2.1.2
optionalDependencies:
magicast: 0.3.5
@@ -12145,7 +12069,7 @@ snapshots:
caniuse-lite@1.0.30001687: {}
caniuse-lite@1.0.30001727:
caniuse-lite@1.0.30001723:
optional: true
ccount@2.0.1: {}
@@ -12621,7 +12545,7 @@ snapshots:
ee-first@1.1.1: {}
electron-to-chromium@1.5.182:
electron-to-chromium@1.5.167:
optional: true
electron-to-chromium@1.5.71: {}
@@ -12652,7 +12576,7 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.2.1
enhanced-resolve@5.18.2:
enhanced-resolve@5.18.1:
dependencies:
graceful-fs: 4.2.11
tapable: 2.2.2
@@ -14351,7 +14275,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 22.4.1
'@types/node': 20.14.11
merge-stream: 2.0.0
supports-color: 8.1.1
optional: true
@@ -14524,7 +14448,7 @@ snapshots:
local-pkg@1.1.1:
dependencies:
mlly: 1.7.4
pkg-types: 2.2.0
pkg-types: 2.1.1
quansync: 0.2.10
optional: true
@@ -15521,7 +15445,7 @@ snapshots:
citty: 0.1.6
consola: 3.4.2
pathe: 2.0.3
pkg-types: 2.2.0
pkg-types: 2.1.1
tinyexec: 0.3.2
optional: true
@@ -15809,7 +15733,7 @@ snapshots:
pathe: 2.0.3
optional: true
pkg-types@2.2.0:
pkg-types@2.1.1:
dependencies:
confbox: 0.2.2
exsolve: 1.0.7
@@ -16012,7 +15936,7 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.6:
postcss@8.5.5:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
@@ -17067,11 +16991,11 @@ snapshots:
terser-webpack-plugin@5.3.14(webpack@5.92.1):
dependencies:
'@jridgewell/trace-mapping': 0.3.29
'@jridgewell/trace-mapping': 0.3.25
jest-worker: 27.5.1
schema-utils: 4.3.2
serialize-javascript: 6.0.2
terser: 5.43.1
terser: 5.42.0
webpack: 5.92.1
optional: true
@@ -17082,14 +17006,6 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
terser@5.43.1:
dependencies:
'@jridgewell/source-map': 0.3.10
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
optional: true
text-decoder@1.1.0:
dependencies:
b4a: 1.6.6
@@ -17365,7 +17281,7 @@ snapshots:
mlly: 1.7.4
pathe: 2.0.3
picomatch: 4.0.2
pkg-types: 2.2.0
pkg-types: 2.1.1
scule: 1.3.0
strip-literal: 3.0.0
tinyglobby: 0.2.14
@@ -17536,9 +17452,9 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
update-browserslist-db@1.1.3(browserslist@4.25.1):
update-browserslist-db@1.1.3(browserslist@4.25.0):
dependencies:
browserslist: 4.25.1
browserslist: 4.25.0
escalade: 3.2.0
picocolors: 1.1.1
optional: true
@@ -17660,16 +17576,16 @@ snapshots:
svgo: 3.3.2
vue: 3.5.13(typescript@5.5.4)
vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.43.1):
vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0):
dependencies:
esbuild: 0.18.20
postcss: 8.5.6
postcss: 8.5.5
rollup: 3.29.4
optionalDependencies:
'@types/node': 22.4.1
fsevents: 2.3.3
sass: 1.77.6
terser: 5.43.1
terser: 5.42.0
optional: true
vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0):
@@ -17945,7 +17861,7 @@ snapshots:
webidl-conversions@3.0.1: {}
webpack-sources@3.3.3:
webpack-sources@3.3.2:
optional: true
webpack-virtual-modules@0.6.2: {}
@@ -17959,9 +17875,9 @@ snapshots:
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.15.0
acorn-import-attributes: 1.9.5(acorn@8.15.0)
browserslist: 4.25.1
browserslist: 4.25.0
chrome-trace-event: 1.0.4
enhanced-resolve: 5.18.2
enhanced-resolve: 5.18.1
es-module-lexer: 1.7.0
eslint-scope: 5.1.1
events: 3.3.0
@@ -17975,7 +17891,7 @@ snapshots:
tapable: 2.2.2
terser-webpack-plugin: 5.3.14(webpack@5.92.1)
watchpack: 2.4.4
webpack-sources: 3.3.3
webpack-sources: 3.3.2
transitivePeerDependencies:
- '@swc/core'
- esbuild