Compare commits

...
Author SHA1 Message Date
Prospector f4a6d6c6a9 fix: invalid auth cookie causing page not to load 2026-05-23 16:17:30 -07:00
Calum H.andGitHub 8b17441f40 feat: compact logs if they have logspam to prevent app crashing (#6181)
* feat: compact logs if they have logspam to prevent app crashing

* fix: lint
2026-05-23 18:22:15 +00:00
f9d47e8edc feat: Add new "Enabled" sorting button next to "Disabled" (#6000)
* feat: Added new "Enabled" sorting button next to "Disabled"

* Updated when filter buttons show

- When all mods are disabled, only "Disabled" shows
- When all mods are enabled, only "Enabled" shows
- When there disabled and enabled mods, then only both buttons show.

* Updated when filter buttons show

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-05-23 16:12:14 +00:00
a58bc3dc21 feat: java installation ui improvements (#5731)
* Clean impl of java installation ui improvements

* Migrate composable to ts

* Migrate to ButtonStyled, fix coloring

* Fix lint

* Fix clearing java path not refreshing state

* fix: use Table component + install btn disabled state tooltip

---------

Signed-off-by: Arthur <creeperkatze.dev@gmail.com>
Signed-off-by: Arthur <contact@creeperkatze.dev>
Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-05-23 14:46:12 +00:00
ProspectorandGitHub 1e46444fb0 fix: checklist forcing redirects (#6173) 2026-05-22 15:53:22 -07:00
ProspectorandGitHub 1511e55597 fix: skip reviewed projects in queue (#6171) 2026-05-22 14:54:50 -07:00
aecsocketandGitHub 5727e156ed Fetch project analytics events on analytics get (#6143)
* Fetch project analytics events

* fix

* post-query ua bucketing

* fmt
2026-05-22 18:32:33 +00:00
Calum H.andGitHub 657186398d fix: remove broken roadmap reference (#6165)
Signed-off-by: Calum H. <calum@modrinth.com>
2026-05-22 18:10:01 +00:00
Michael H. 3ab2273782 chore: prepr 2026-05-22 19:58:11 +02:00
ProspectorandGitHub 893ec00fc6 feat: add external dep sorting to moderation queue (#6161)
* feat: add external dep sorting to moderation queue

* prepr
2026-05-21 16:40:13 -07:00
Prospector aa7dd1d210 web 2026-05-21 16:23:30 -07:00
Calum H.andGitHub f8733b0488 feat: changelog 21st may 2026 (#6158)
* feat: changelog 21st may 2026

* fix: changelog
2026-05-21 22:27:14 +00:00
Calum H.andGitHub d077d44540 fix: content tab uniqueness regression (#6156)
* fix: content tab uniqueness regression

Closes: #6154

* fix: further regressions

* fix: lint

* fix: lint
2026-05-21 22:03:35 +00:00
aecsocketandGitHub 4e1a61d8b6 Adjust window occlusion threshold (#6157) 2026-05-21 21:48:39 +00:00
aecsocketandGitHub 71dee4de40 Add modpacks with external files filter to moderation queue (#6155)
* simplify query

* make it a tristate

* external deps count
2026-05-21 20:21:54 +00:00
SychicandGitHub f74fad0cae fix: typo in version changelog (#6153) 2026-05-21 19:38:11 +00:00
Truman GaoandGitHub 07e81ac036 fix: project embeds (#6152)
* fix: project embeds

* remove: params.id
2026-05-21 17:02:53 +00:00
Calum H.andGitHub 6e7835fb35 feat: implement kryos upload sessions (#6145)
* feat: implement upload sessions

* fix: files not scoped

* feat: hide staging files folder and proper cancel feedback

* fix: lint
2026-05-21 16:49:48 +00:00
Calum H.andGitHub 2f95c4c441 feat: changelog 20th may 2026 (#6148) 2026-05-20 12:51:36 -07:00
aecsocketandGitHub 451b2d0e44 Window occlusion checks on MacOS (#6135)
* wip: window occlusion checks on MacOS

* wip: occlusion works

* occlusion notification

* fix ci

* fix

* wire in hiding into macos occlusion

* remove debug logs

* fix

* clean up
2026-05-20 19:31:12 +00:00
Truman GaoandGitHub 215643c846 fix: server project type page title (#6147)
* fix: server project type page title

* pnpm prepr
2026-05-20 19:01:19 +00:00
Truman GaoandGitHub d8b1415f9c feat: swap date input to use date picker (#6146)
* feat: swap date input to use date picker

* feat: update date picker to analytics branch changes

* feat: polish date picker usage
2026-05-20 17:50:46 +00:00
Calum H.andGitHub 3eeb549d20 fix: intercom bubble positioning properly (#6111)
* feat: fix intercom properly

* fix: positioning size + css transition

* fix: lint

* fix: ts

* fix: nitpick
2026-05-20 17:15:46 +00:00
Calum H.andGitHub c3fe7b4232 feat: content management changes (#6104)
* feat: change modpack updating flow

* fix: pending install state loss

* fix: mods.vue perf problems

* chore: todo doc

* draft: try preload/fix suspense

* fix: lint
2026-05-20 17:07:35 +00:00
Prospector 079a10bba9 changelog 2026-05-20 09:01:26 -07:00
Prospector 3c3d5702ba fix: permissions page being displayed 2026-05-20 08:59:50 -07:00
Prospector a34576a2c3 changelog 2026-05-19 12:51:59 -07:00
ProspectorandGitHub d8e4915a31 fix: 404 error when changing project slug (#6139) 2026-05-19 19:21:23 +00:00
Prospector ed723fa186 changelog 2026-05-19 11:50:49 -07:00
ProspectorandGitHub d6c8d4475b feat: new north american region info (#6091) 2026-05-19 11:49:41 -07:00
ProspectorandGitHub 302b60d89c fix: random 404s (#6138)
* fix: random 404s when navigating from user, org, or collection page to project

* more

* revert server

* clean names

* prepr
2026-05-19 18:11:48 +00:00
aecsocketandGitHub f106dc580f Restrict what tokens can be used for auth init flows (#6137) 2026-05-19 15:45:58 +00:00
aecsocketandGitHub 244c263e40 Implement analytics marker events (#6090)
* Analytics events

* prepare

* change route prefix

* update route return

* Add mod launcher analytics

* more UA strings

* fix ci

* caching on analytics events

* Return parent modpack versions for playtime queries

* sqlx prepare

* fmt

* dummy fixtures
2026-05-19 13:06:04 +00:00
158 changed files with 5585 additions and 1476 deletions
@@ -9,8 +9,6 @@ body:
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate feature requests
required: true
- label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com)
required: true
- type: dropdown
id: projects
attributes:
Generated
+3
View File
@@ -10565,6 +10565,8 @@ version = "1.0.0-local"
dependencies = [
"async_zip",
"chrono",
"core-foundation 0.10.1",
"core-graphics",
"daedalus",
"dashmap",
"either",
@@ -10572,6 +10574,7 @@ dependencies = [
"hyper 1.7.0",
"hyper-util",
"native-dialog",
"objc2-app-kit",
"paste",
"path-util",
"serde",
+3
View File
@@ -64,6 +64,8 @@ clickhouse = "0.14.0"
color-eyre = "0.6.5"
color-thief = "0.2.2"
const_format = "0.2.34"
core-foundation = "0.10.1"
core-graphics = "0.24.0"
daedalus = { path = "packages/daedalus" }
darling = { version = "0.23" }
dashmap = "6.1.0"
@@ -123,6 +125,7 @@ murmur2 = "0.1.0"
native-dialog = "0.9.2"
notify = { version = "8.2.0", default-features = false }
notify-debouncer-mini = { version = "0.7.0", default-features = false }
objc2-app-kit = { version = "0.3.2", default-features = false }
p256 = "0.13.2"
parking_lot = "0.12.5"
paste = "1.0.15"
-1
View File
@@ -13,7 +13,6 @@
"test": "vue-tsc --noEmit"
},
"dependencies": {
"@intercom/messenger-js-sdk": "^0.0.14",
"@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*",
+41 -93
View File
@@ -1,5 +1,4 @@
<script setup>
import { Intercom, shutdown as shutdownIntercom } from '@intercom/messenger-js-sdk'
import {
AuthFeature,
ModrinthApiError,
@@ -53,6 +52,7 @@ import {
providePopupNotificationManager,
useDebugLogger,
useFormatBytes,
useHostingIntercom,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
@@ -88,7 +88,6 @@ import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useIntercomPositioning } from '@/composables/intercom-positioning'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { config } from '@/config'
import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
@@ -132,15 +131,36 @@ import { AppPopupNotificationManager } from './providers/app-popup-notifications
const themeStore = useTheming()
const router = useRouter()
const route = useRoute()
const intercomBubblePositioning = useIntercomPositioning({ route, themeStore })
const {
sidebarToggled,
forceSidebar,
sidebarVisible,
intercomBubblePosition,
updateIntercomBubbleStyles,
clearIntercomBubbleStyles,
} = intercomBubblePositioning
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const credentials = ref()
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
})
const forceSidebar = computed(
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
)
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
const hostingIntercomIdentityKey = computed(() => {
const rawServerId = route.params.id
const serverId = Array.isArray(rawServerId) ? rawServerId[0] : rawServerId
const userId = credentials.value?.user_id ?? credentials.value?.user?.id ?? 'anonymous'
return `${userId}:${serverId ?? 'hosting'}`
})
const hostingIntercom = useHostingIntercom({
enabled: computed(() => hostingRouteActive.value && !!credentials.value?.session),
appId: 'ykeritl9',
fetchToken: fetchIntercomToken,
identityKey: hostingIntercomIdentityKey,
horizontalPadding: computed(() =>
sidebarVisible.value
? APP_SIDEBAR_WIDTH + INTERCOM_BUBBLE_DEFAULT_PADDING
: INTERCOM_BUBBLE_DEFAULT_PADDING,
),
})
const notificationManager = new AppNotificationManager()
provideNotificationManager(notificationManager)
@@ -175,7 +195,11 @@ provideModrinthClient(tauriApiClient)
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
...intercomBubblePositioning.pageContext,
floatingActionBarOffsets: {
left: ref(APP_LEFT_NAV_WIDTH),
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
},
intercomBubble: hostingIntercom.intercomBubble,
featureFlags: {
serverRamAsBytesAlwaysOn: computed(() =>
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
@@ -259,8 +283,7 @@ onMounted(async () => {
onUnmounted(async () => {
document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
shutdownHostingIntercom()
clearIntercomBubbleStyles()
unsubscribeSidebarToggle()
await unlistenUpdateDownload?.()
})
@@ -593,8 +616,6 @@ const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
const updateToPlayModal = ref()
const credentials = ref()
const modrinthLoginFlowWaitModal = ref()
setupAuthProvider(credentials, async (_redirectPath) => {
@@ -664,10 +685,6 @@ const hasPlus = computed(
const showAd = computed(
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
)
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
let intercomBooting = false
let intercomBooted = false
async function fetchIntercomToken() {
const creds = await getCreds()
@@ -676,8 +693,10 @@ async function fetchIntercomToken() {
}
const params = new URLSearchParams()
if (route.path.startsWith('/hosting/manage/') && typeof route.params.id === 'string') {
params.set('server_id', route.params.id)
const rawServerId = route.params.id
const serverId = Array.isArray(rawServerId) ? rawServerId[0] : rawServerId
if (route.path.startsWith('/hosting/manage/') && typeof serverId === 'string') {
params.set('server_id', serverId)
}
const query = params.size > 0 ? `?${params.toString()}` : ''
@@ -693,69 +712,6 @@ async function fetchIntercomToken() {
return await response.json()
}
async function bootIntercom() {
if (
intercomBooting ||
intercomBooted ||
!hostingRouteActive.value ||
!credentials.value?.session
) {
return
}
intercomBooting = true
console.debug('[APP][INTERCOM] initializing secure support chat')
try {
const { token } = await fetchIntercomToken()
Intercom({
app_id: 'ykeritl9',
intercom_user_jwt: token,
session_duration: 1000 * 60 * 60 * 24,
alignment: 'right',
horizontal_padding: intercomBubblePosition.value.horizontalPadding,
vertical_padding: intercomBubblePosition.value.verticalPadding,
})
intercomBooted = true
} catch (error) {
console.warn('[APP][INTERCOM] failed to initialize secure support chat', error)
} finally {
intercomBooting = false
}
}
function shutdownHostingIntercom() {
if (!intercomBooted && !intercomBooting) return
shutdownIntercom()
intercomBooting = false
intercomBooted = false
}
watch(
intercomBubblePosition,
(position) => {
updateIntercomBubbleStyles(position)
if (intercomBooted) {
window.Intercom?.('update', {
horizontal_padding: position.horizontalPadding,
vertical_padding: position.verticalPadding,
})
}
},
{ immediate: true },
)
watch(
[hostingRouteActive, credentials],
([active]) => {
if (active) {
void bootIntercom()
} else {
shutdownHostingIntercom()
}
},
{ immediate: true },
)
watch(showAd, () => {
if (!showAd.value) {
hide_ads_window(true)
@@ -1822,14 +1778,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
--os-handle-bg-active: var(--color-scrollbar) !important;
}
.intercom-lightweight-app-launcher,
.intercom-launcher-frame,
iframe[name='intercom-launcher-frame'] {
right: var(--app-support-launcher-right, 20px) !important;
bottom: var(--app-support-launcher-bottom, 20px) !important;
z-index: 9 !important;
}
.mac {
.app-grid-statusbar {
padding-left: 5rem;
@@ -1,35 +1,33 @@
<template>
<ModalWrapper ref="detectJavaModal" header="Select java version" :show-ad-on-close="false">
<div class="auto-detect-modal">
<div class="table">
<div class="table-row table-head">
<div class="table-cell table-text">Version</div>
<div class="table-cell table-text">Path</div>
<div class="table-cell table-text">Actions</div>
</div>
<div v-for="javaInstall in chosenInstallOptions" :key="javaInstall.path" class="table-row">
<div class="table-cell table-text">
<span>{{ javaInstall.version }}</span>
</div>
<div v-tooltip="javaInstall.path" class="table-cell table-text">
<span>{{ javaInstall.path }}</span>
</div>
<div class="table-cell table-text manage">
<ButtonStyled v-if="currentSelected.path === javaInstall.path">
<button disabled><CheckIcon /> Selected</button>
<div class="flex flex-col gap-4">
<Table :columns="javaInstallColumns" :data="chosenInstallOptions" row-key="path">
<template #cell-version="{ value }">
<span class="font-semibold text-primary">{{ value }}</span>
</template>
<template #cell-path="{ value }">
<span v-tooltip="value" class="block truncate font-mono text-xs">{{ value }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end">
<ButtonStyled v-if="currentSelected.path === row.path">
<button class="!shadow-none" disabled><CheckIcon /> Selected</button>
</ButtonStyled>
<ButtonStyled v-else>
<button @click="setJavaInstall(javaInstall)"><PlusIcon /> Select</button>
<button class="!shadow-none" @click="setJavaInstall(row)"><PlusIcon /> Select</button>
</ButtonStyled>
</div>
</div>
<div v-if="chosenInstallOptions.length === 0" class="table-row entire-row">
<div class="table-cell table-text">No java installations found!</div>
</div>
</div>
<div class="input-group push-right">
</template>
<template #empty-state>
<div class="p-4 text-secondary">No java installations found!</div>
</template>
</Table>
<div class="flex justify-end">
<ButtonStyled type="outlined">
<button @click="$refs.detectJavaModal.hide()">
<button
class="!shadow-none !border-surface-4 !border"
@click="$refs.detectJavaModal.hide()"
>
<XIcon />
Cancel
</button>
@@ -40,7 +38,7 @@
</template>
<script setup>
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { ButtonStyled, injectNotificationManager, Table } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
@@ -52,6 +50,11 @@ const { handleError } = injectNotificationManager()
const chosenInstallOptions = ref([])
const detectJavaModal = ref(null)
const currentSelected = ref({})
const javaInstallColumns = [
{ key: 'version', label: 'Version', width: '9rem' },
{ key: 'path', label: 'Path' },
{ key: 'actions', label: 'Actions', align: 'right', width: '10rem' },
]
defineExpose({
show: async (version, currentSelectedJava) => {
@@ -77,25 +80,3 @@ function setJavaInstall(javaInstall) {
})
}
</script>
<style lang="scss" scoped>
.auto-detect-modal {
.table {
.table-row {
grid-template-columns: 1fr 4fr min-content;
}
span {
display: inherit;
align-items: center;
justify-content: center;
}
padding: 0.5rem;
}
}
.manage {
display: flex;
gap: 0.5rem;
}
</style>
@@ -1,85 +1,104 @@
<template>
<JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" />
<div class="toggle-setting" :class="{ compact }">
<StyledInput
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
:placeholder="placeholder ?? '/path/to/java'"
wrapper-class="installation-input"
@update:model-value="
(val) => {
emit('update:modelValue', {
...props.modelValue,
path: val,
})
}
"
/>
<div :id="props.id" class="toggle-setting" :class="{ compact }">
<div class="input-with-status">
<StyledInput
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
:placeholder="placeholder ?? '/path/to/java'"
wrapper-class="installation-input"
@update:model-value="
(val) => {
emit('update:modelValue', {
...props.modelValue,
path: val,
})
}
"
/>
<ButtonStyled
:color="
!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard'
"
color-fill="text"
>
<button
class="!shadow-none"
:disabled="testingJava || props.disabled"
@click="runTest(props.modelValue?.path)"
@mouseenter="!props.disabled && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="testingJavaSuccess === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="testingJavaSuccess !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="!props.disabled" class="h-4 w-4" />
</button>
</ButtonStyled>
</div>
<span class="installation-buttons">
<ButtonStyled v-if="props.version">
<button :disabled="props.disabled || installingJava" @click="reinstallJava">
<button
v-tooltip="testingJavaSuccess === true ? 'Already installed' : undefined"
class="!shadow-none"
:disabled="props.disabled || installingJava || testingJavaSuccess === true"
@click="reinstallJava"
>
<DownloadIcon />
{{ installingJava ? 'Installing...' : 'Install recommended' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="props.disabled" @click="autoDetect">
<button class="!shadow-none" :disabled="props.disabled" @click="autoDetect">
<SearchIcon />
Detect
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="props.disabled" @click="handleJavaFileInput()">
<button class="!shadow-none" :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon />
Browse
</button>
</ButtonStyled>
<ButtonStyled v-if="testingJava">
<button disabled>Testing...</button>
</ButtonStyled>
<ButtonStyled v-else-if="testingJavaSuccess === true">
<button disabled>
<CheckIcon />
Success
</button>
</ButtonStyled>
<ButtonStyled v-else-if="testingJavaSuccess === false">
<button disabled>
<XIcon />
Failed
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button :disabled="props.disabled" @click="testJava">
<PlayIcon />
Test
</button>
</ButtonStyled>
</span>
</div>
</template>
<script setup>
import {
CheckIcon,
CheckCircleIcon,
DownloadIcon,
FolderSearchIcon,
PlayIcon,
RefreshCwIcon,
SearchIcon,
XIcon,
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, StyledInput } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import useJavaTest from '@/composables/useJavaTest'
import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager()
const props = defineProps({
id: {
type: String,
required: false,
default: null,
},
version: {
type: Number,
required: false,
@@ -110,29 +129,36 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const testingJava = ref(false)
const testingJavaSuccess = ref(null)
const {
testingJava,
javaTestResult: testingJavaSuccess,
testJavaInstallationDebounced,
testJavaInstallation,
} = useJavaTest()
const installingJava = ref(false)
const hoveringTest = ref(false)
let hasInitialized = false
async function testJava() {
testingJava.value = true
testingJavaSuccess.value = await test_jre(
props.modelValue ? props.modelValue.path : '',
props.version,
)
testingJava.value = false
trackEvent('JavaTest', {
path: props.modelValue ? props.modelValue.path : '',
success: testingJavaSuccess.value,
})
setTimeout(() => {
testingJavaSuccess.value = null
}, 2000)
async function runTest(path) {
await testJavaInstallation(path, props.version, true)
}
watch(
() => props.modelValue?.path,
(newPath) => {
if (newPath) {
if (!hasInitialized) {
testJavaInstallation(newPath, props.version, false)
hasInitialized = true
} else {
testJavaInstallationDebounced(newPath, props.version)
}
}
},
{ immediate: true },
)
async function handleJavaFileInput() {
const filePath = await open()
@@ -142,6 +168,7 @@ async function handleJavaFileInput() {
result = {
path: filePath.path ?? filePath,
version: props.version.toString(),
parsed_version: props.version,
architecture: 'x86',
}
}
@@ -175,6 +202,7 @@ async function reinstallJava() {
result = {
path: path,
version: props.version.toString(),
parsed_version: props.version,
architecture: 'x86',
}
}
@@ -186,13 +214,23 @@ async function reinstallJava() {
emit('update:modelValue', result)
installingJava.value = false
runTest(result.path)
}
</script>
<style lang="scss" scoped>
.input-with-status {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
width: 100%;
min-width: 0;
}
.installation-input {
width: 100% !important;
flex-grow: 1;
flex: 1 1 0;
min-width: 0;
}
.toggle-setting {
@@ -215,12 +253,4 @@ async function reinstallJava() {
gap: 0.5rem;
margin: 0;
}
.test-success {
color: var(--color-green);
}
.test-fail {
color: var(--color-red);
}
</style>
@@ -35,7 +35,7 @@ const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const { instance, offline, isMinecraftServer, onUnlinked } = injectInstanceSettings()
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
const [
fabric_versions,
@@ -113,6 +113,7 @@ provideAppBackup({
})
provideInstallationSettings({
closeSettings: closeModal,
loading: ref(false),
installationInfo: computed(() => {
const rows = [
@@ -1,6 +1,15 @@
<script setup lang="ts">
import { CheckCircleIcon, XCircleIcon } from '@modrinth/assets'
import {
CheckCircleIcon,
CoffeeIcon,
FolderSearchIcon,
RefreshCwIcon,
SearchIcon,
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
defineMessages,
injectNotificationManager,
@@ -8,9 +17,11 @@ import {
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { computed, readonly, ref, watch } from 'vue'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import useJavaTest from '@/composables/useJavaTest'
import useMemorySlider from '@/composables/useMemorySlider'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
@@ -25,9 +36,54 @@ const { instance } = injectInstanceSettings()
const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
const overrideJavaInstall = ref(!!instance.value.java_path)
const optimalJava = readonly(await get_optimal_jre_key(instance.value.path).catch(handleError))
const javaInstall = ref({ path: optimalJava.path ?? instance.value.java_path })
const overrideJavaInstall = ref(!!instance.value.java_path)
const javaPath = ref(instance.value.java_path ?? optimalJava?.path ?? '')
const activePath = computed(() =>
overrideJavaInstall.value ? javaPath.value : (optimalJava?.path ?? ''),
)
watch(overrideJavaInstall, (enabled) => {
if (enabled && !javaPath.value) {
javaPath.value = optimalJava?.path ?? ''
}
})
const { testingJava, javaTestResult, testJavaInstallationDebounced, testJavaInstallation } =
useJavaTest()
const hoveringTest = ref(false)
let hasInitialized = false
watch(
activePath,
(newPath) => {
if (newPath && optimalJava?.parsed_version) {
if (!hasInitialized) {
testJavaInstallation(newPath, optimalJava?.parsed_version, false)
hasInitialized = true
} else {
testJavaInstallationDebounced(newPath, optimalJava?.parsed_version)
}
}
},
{ immediate: true },
)
const javaDetectionModal = ref<{ show: (version: number, current: object) => void } | null>(null)
async function handleBrowseJava() {
const result = await open({ multiple: false })
if (result) {
javaPath.value = result
}
}
function handleDetectJava() {
javaDetectionModal.value?.show(optimalJava?.parsed_version, { path: javaPath.value })
}
const overrideJavaArgs = ref((instance.value.extra_launch_args?.length ?? 0) > 0)
const javaArgs = ref(
@@ -51,8 +107,8 @@ const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) a
const editProfileObject = computed(() => {
return {
java_path:
overrideJavaInstall.value && javaInstall.value.path !== ''
? javaInstall.value.path.replace('java.exe', 'javaw.exe')
overrideJavaInstall.value && javaPath.value
? javaPath.value.replace('java.exe', 'javaw.exe')
: null,
extra_launch_args: overrideJavaArgs.value
? javaArgs.value.trim().split(/\s+/).filter(Boolean)
@@ -71,7 +127,7 @@ const editProfileObject = computed(() => {
watch(
[
overrideJavaInstall,
javaInstall,
javaPath,
overrideJavaArgs,
javaArgs,
overrideEnvVars,
@@ -90,17 +146,45 @@ const messages = defineMessages({
id: 'instance.settings.tabs.java.java-installation',
defaultMessage: 'Java installation',
},
customJavaInstallation: {
id: 'instance.settings.tabs.java.custom-java-installation',
defaultMessage: 'Custom Java installation',
},
javaPathPlaceholder: {
id: 'instance.settings.tabs.java.java-path-placeholder',
defaultMessage: '/path/to/java',
},
javaMemory: {
id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated',
},
customMemoryAllocation: {
id: 'instance.settings.tabs.java.custom-memory-allocation',
defaultMessage: 'Custom memory allocation',
},
javaArguments: {
id: 'instance.settings.tabs.java.java-arguments',
defaultMessage: 'Java arguments',
},
customJavaArguments: {
id: 'instance.settings.tabs.java.custom-java-arguments',
defaultMessage: 'Custom Java arguments',
},
enterJavaArguments: {
id: 'instance.settings.tabs.java.enter-java-arguments',
defaultMessage: 'Enter Java arguments...',
},
javaEnvironmentVariables: {
id: 'instance.settings.tabs.java.environment-variables',
defaultMessage: 'Environment variables',
},
javaMemory: {
id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated',
customEnvironmentVariables: {
id: 'instance.settings.tabs.java.custom-environment-variables',
defaultMessage: 'Custom environment variables',
},
enterEnvironmentVariables: {
id: 'instance.settings.tabs.java.enter-environment-variables',
defaultMessage: 'Enter environmental variables...',
},
hooks: {
id: 'instance.settings.tabs.java.hooks',
@@ -111,43 +195,86 @@ const messages = defineMessages({
<template>
<div>
<h2 id="project-name" class="m-0 mb-2.5 text-lg font-semibold text-contrast block">
<JavaDetectionModal ref="javaDetectionModal" @submit="(val) => (javaPath = val.path)" />
<h2 class="m-0 mb-2 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaInstallation) }}
</h2>
<Checkbox v-model="overrideJavaInstall" label="Custom Java installation" class="mb-2.5" />
<template v-if="!overrideJavaInstall">
<div class="flex my-2 items-center gap-2 font-semibold">
<template v-if="javaInstall">
<CheckCircleIcon class="text-brand-green h-4 w-4" />
<span>Using default Java {{ optimalJava.major_version }} installation:</span>
</template>
<template v-else-if="optimalJava">
<XCircleIcon class="text-brand-red h-5 w-5" />
<span
>Could not find a default Java {{ optimalJava.major_version }} installation. Please set
one below:</span
<Checkbox
v-model="overrideJavaInstall"
:label="formatMessage(messages.customJavaInstallation)"
class="mb-2"
/>
<div class="flex gap-4 p-4 bg-bg rounded-2xl">
<div class="flex gap-3 items-start flex-1 min-w-0">
<div
class="w-10 h-10 flex items-center justify-center rounded-full bg-button-bg border-solid border-[1px] border-button-border p-2 mt-1 shrink-0 [&_svg]:h-full [&_svg]:w-full"
>
<CoffeeIcon />
</div>
<div class="flex flex-col gap-2 flex-1 min-w-0">
<span class="font-semibold leading-none mt-2"
>Java {{ optimalJava?.parsed_version }}</span
>
</template>
<template v-else>
<XCircleIcon class="text-brand-red h-5 w-5" />
<span
>Could not automatically determine a Java installation to use. Please set one
below:</span
>
</template>
<div class="flex gap-2 items-center">
<StyledInput
:model-value="activePath"
:disabled="!overrideJavaInstall"
autocomplete="off"
:placeholder="formatMessage(messages.javaPathPlaceholder)"
wrapper-class="flex-1 min-w-0"
@update:model-value="(val) => (javaPath = String(val))"
/>
<ButtonStyled
:color="
!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard'
"
color-fill="text"
>
<button
:disabled="!overrideJavaInstall || testingJava"
@click="testJavaInstallation(activePath, optimalJava?.parsed_version, true)"
@mouseenter="overrideJavaInstall && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="javaTestResult === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="javaTestResult !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="overrideJavaInstall" class="h-4 w-4" />
</button>
</ButtonStyled>
</div>
<div v-if="overrideJavaInstall" class="flex gap-2">
<ButtonStyled>
<button @click="handleDetectJava">
<SearchIcon />
Detect
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleBrowseJava">
<FolderSearchIcon />
Browse
</button>
</ButtonStyled>
</div>
</div>
</div>
<div
v-if="javaInstall && !overrideJavaInstall"
class="p-4 bg-bg rounded-xl text-xs text-secondary leading-none font-mono"
>
{{ javaInstall.path }}
</div>
</template>
<JavaSelector v-if="overrideJavaInstall || !javaInstall" v-model="javaInstall" />
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
</div>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaMemory) }}
</h2>
<Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2.5" />
<Checkbox
v-model="overrideMemorySettings"
:label="formatMessage(messages.customMemoryAllocation)"
class="mb-2"
/>
<Slider
id="max-memory"
v-model="memory.maximum"
@@ -159,28 +286,36 @@ const messages = defineMessages({
:snap-range="512"
unit="MB"
/>
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaArguments) }}
</h2>
<Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" />
<Checkbox
v-model="overrideJavaArgs"
:label="formatMessage(messages.customJavaArguments)"
class="my-2"
/>
<StyledInput
id="java-args"
v-model="javaArgs"
autocomplete="off"
:disabled="!overrideJavaArgs"
placeholder="Enter java arguments..."
:placeholder="formatMessage(messages.enterJavaArguments)"
wrapper-class="w-full"
/>
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaEnvironmentVariables) }}
</h2>
<Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2.5" />
<Checkbox
v-model="overrideEnvVars"
:label="formatMessage(messages.customEnvironmentVariables)"
class="mb-2"
/>
<StyledInput
id="env-vars"
v-model="envVars"
autocomplete="off"
:disabled="!overrideEnvVars"
placeholder="Enter environmental variables..."
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
wrapper-class="w-full"
/>
</div>
@@ -45,12 +45,19 @@ const isMinecraftServer = ref(false)
const handleUnlinked = () => emit('unlinked')
const instanceRef = computed(() => props.instance)
const queryClient = useQueryClient()
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
function hide() {
tabbedModal.value?.hide()
}
provideInstanceSettings({
instance: instanceRef,
offline: props.offline,
isMinecraftServer,
onUnlinked: handleUnlinked,
closeModal: hide,
})
watch(
@@ -113,9 +120,6 @@ const tabs = computed<TabbedModalTab[]>(() => [
},
])
const queryClient = useQueryClient()
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
function show(tabIndex?: number) {
if (props.instance.linked_data?.project_id) {
queryClient.prefetchQuery({
@@ -129,7 +133,7 @@ function show(tabIndex?: number) {
}
}
defineExpose({ show })
defineExpose({ show, hide })
</script>
<template>
<TabbedModal
@@ -1,121 +0,0 @@
import { computed, onUnmounted, ref } from 'vue'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
interface ThemeStore {
toggleSidebar: boolean
$subscribe: (callback: () => void) => () => void
}
interface IntercomBubblePosition {
horizontalPadding: number
verticalPadding: number
}
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const INTERCOM_BUBBLE_WIDTH = 72
const INTERCOM_BUBBLE_RIGHT_VAR = '--app-support-launcher-right'
const INTERCOM_BUBBLE_BOTTOM_VAR = '--app-support-launcher-bottom'
export function useIntercomPositioning({
route,
themeStore,
}: {
route: RouteLocationNormalizedLoaded
themeStore: ThemeStore
}) {
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
})
onUnmounted(unsubscribeSidebarToggle)
const forceSidebar = computed(
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
)
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const defaultIntercomBubbleHorizontalPadding = computed(() =>
sidebarVisible.value
? APP_SIDEBAR_WIDTH + INTERCOM_BUBBLE_DEFAULT_PADDING
: INTERCOM_BUBBLE_DEFAULT_PADDING,
)
const intercomBubbleRequestedHorizontalPadding = ref<number | null>(null)
const intercomBubbleHorizontalPadding = computed(
() =>
intercomBubbleRequestedHorizontalPadding.value ??
defaultIntercomBubbleHorizontalPadding.value,
)
const intercomBubbleVerticalClearance = ref<number | null>(null)
const intercomBubblePosition = computed(() => ({
horizontalPadding: intercomBubbleHorizontalPadding.value,
verticalPadding: intercomBubbleVerticalClearance.value ?? INTERCOM_BUBBLE_DEFAULT_PADDING,
}))
const intercomBubbleHorizontalPaddingRequests = new Map<symbol, number>()
const intercomBubbleClearanceRequests = new Map<symbol, number>()
function requestIntercomBubbleHorizontalPadding(id: symbol, padding: number | null) {
if (padding === null) {
intercomBubbleHorizontalPaddingRequests.delete(id)
} else {
intercomBubbleHorizontalPaddingRequests.set(id, padding)
}
intercomBubbleRequestedHorizontalPadding.value =
intercomBubbleHorizontalPaddingRequests.size > 0
? Math.max(...intercomBubbleHorizontalPaddingRequests.values())
: null
}
function requestIntercomBubbleVerticalClearance(id: symbol, clearance: number | null) {
if (clearance === null) {
intercomBubbleClearanceRequests.delete(id)
} else {
intercomBubbleClearanceRequests.set(id, clearance)
}
intercomBubbleVerticalClearance.value =
intercomBubbleClearanceRequests.size > 0
? Math.max(...intercomBubbleClearanceRequests.values())
: null
}
function updateIntercomBubbleStyles({
horizontalPadding,
verticalPadding,
}: IntercomBubblePosition) {
if (typeof document === 'undefined') return
document.documentElement.style.setProperty(INTERCOM_BUBBLE_RIGHT_VAR, `${horizontalPadding}px`)
document.documentElement.style.setProperty(INTERCOM_BUBBLE_BOTTOM_VAR, `${verticalPadding}px`)
}
function clearIntercomBubbleStyles() {
if (typeof document === 'undefined') return
document.documentElement.style.removeProperty(INTERCOM_BUBBLE_RIGHT_VAR)
document.documentElement.style.removeProperty(INTERCOM_BUBBLE_BOTTOM_VAR)
}
return {
sidebarToggled,
forceSidebar,
sidebarVisible,
intercomBubblePosition,
updateIntercomBubbleStyles,
clearIntercomBubbleStyles,
pageContext: {
floatingActionBarOffsets: {
left: ref(APP_LEFT_NAV_WIDTH),
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
},
intercomBubble: {
width: ref(INTERCOM_BUBBLE_WIDTH),
horizontalPadding: intercomBubbleHorizontalPadding,
requestHorizontalPadding: requestIntercomBubbleHorizontalPadding,
requestVerticalClearance: requestIntercomBubbleVerticalClearance,
},
},
}
}
@@ -8,7 +8,7 @@ interface LogEntry {
filename: string
name?: string
log_type: string
stdout?: string
output?: string | null
age?: number
live?: boolean
}
@@ -50,12 +50,12 @@ async function getHistoricalLogs(profilePathId: string, instancePath: string): P
const entry = getOrCreate(profilePathId)
if (entry.logList) return entry.logList
const logs: LogEntry[] = await get_logs(instancePath, false)
const logs: LogEntry[] = await get_logs(instancePath, true)
entry.logList = logs
for (const log of logs) {
if (log.stdout && log.stdout !== '') {
entry.historicalCache.set(log.filename, log.stdout)
if (log.output) {
entry.historicalCache.set(log.filename, log.output)
}
}
@@ -0,0 +1,52 @@
import { ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { test_jre } from '@/helpers/jre.js'
export default function useJavaTest() {
const testingJava = ref(false)
const javaTestResult = ref<boolean | null>(null)
let testDebounceTimer: ReturnType<typeof setTimeout> | null = null
async function runJavaTest(path: string, version: number, track = true) {
if (testDebounceTimer) {
clearTimeout(testDebounceTimer)
testDebounceTimer = null
}
if (!path) {
javaTestResult.value = null
return
}
testingJava.value = true
try {
javaTestResult.value = await test_jre(path, version)
} catch {
javaTestResult.value = false
}
testingJava.value = false
if (track) {
trackEvent('JavaTest', { path, success: javaTestResult.value })
}
}
function testJavaInstallationDebounced(path: string, version: number, delay = 600) {
if (testDebounceTimer) clearTimeout(testDebounceTimer)
if (!path) {
javaTestResult.value = null
return
}
testDebounceTimer = setTimeout(() => runJavaTest(path, version, false), delay)
}
async function testJavaInstallation(path: string, version: number, track = false) {
await runJavaTest(path, version, track)
}
return {
testingJava,
javaTestResult,
testJavaInstallationDebounced,
testJavaInstallation,
}
}
@@ -0,0 +1,100 @@
import type {
ContentItem,
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
ContentOwner,
} from '@modrinth/ui'
import {
get_content_items,
get_linked_modpack_info,
type LinkedModpackInfo,
} from '@/helpers/profile'
import { get_categories } from '@/helpers/tags.js'
import type { CacheBehaviour } from '@/helpers/types'
export type InstanceContentData = {
path: string
contentItems: ContentItem[] | null
modpack: InstanceContentModpackData | null
}
export type InstanceContentModpackData = {
project: ContentModpackCardProject
version: ContentModpackCardVersion
owner: ContentOwner | null
categories: ContentModpackCardCategory[]
hasUpdate: boolean
updateVersionId: string | null
}
export async function loadInstanceContentData(
path: string,
cacheBehaviour?: CacheBehaviour,
onError?: (error: Error) => unknown,
): Promise<InstanceContentData> {
const [contentItems, modpackInfo, allCategories] = await Promise.all([
get_content_items(path, cacheBehaviour).catch((error) => handleLoadError(error, onError)),
get_linked_modpack_info(path, cacheBehaviour).catch((error) => handleLoadError(error, onError)),
get_categories().catch((error) => handleLoadError(error, onError)),
])
return {
path,
contentItems: (contentItems as ContentItem[] | null | undefined) ?? null,
modpack: normalizeLinkedModpackInfo(
modpackInfo as LinkedModpackInfo | null | undefined,
allCategories as ContentModpackCardCategory[] | null | undefined,
),
}
}
function handleLoadError(error: unknown, onError?: (error: Error) => unknown) {
onError?.(error as Error)
return null
}
function normalizeLinkedModpackInfo(
modpackInfo: LinkedModpackInfo | null | undefined,
allCategories: ContentModpackCardCategory[] | null | undefined,
): InstanceContentModpackData | null {
if (!modpackInfo) return null
return {
project: {
...modpackInfo.project,
slug: modpackInfo.project.slug ?? modpackInfo.project.id,
icon_url: modpackInfo.project.icon_url ?? undefined,
},
version: {
...modpackInfo.version,
date_published: modpackInfo.version.date_published.toString(),
},
owner: modpackInfo.owner
? {
...modpackInfo.owner,
avatar_url: modpackInfo.owner.avatar_url ?? undefined,
}
: null,
categories: resolveLinkedModpackCategories(modpackInfo, allCategories),
hasUpdate: modpackInfo.has_update,
updateVersionId: modpackInfo.update_version_id,
}
}
function resolveLinkedModpackCategories(
modpackInfo: LinkedModpackInfo,
allCategories: ContentModpackCardCategory[] | null | undefined,
) {
if (!allCategories || !modpackInfo.project.categories) return []
const seen = new Set<string>()
return allCategories.filter((category) => {
if (modpackInfo.project.categories.includes(category.name) && !seen.has(category.name)) {
seen.add(category.name)
return true
}
return false
})
}
+2 -3
View File
@@ -6,12 +6,11 @@
import { invoke } from '@tauri-apps/api/core'
/*
A log is a struct containing the filename string, stdout, and stderr, as follows:
A log is a struct containing the filename string and optional output, as follows:
pub struct Logs {
pub filename: String,
pub stdout: String,
pub stderr: String,
pub output: Option<String>,
}
*/
+8 -1
View File
@@ -147,9 +147,16 @@ export async function get_mod_full_path(path: string, projectPath: string): Prom
return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath })
}
export interface JavaVersion {
parsed_version: number
version: string
architecture: string
path: string
}
// Get optimal java version from profile
// Returns a java version
export async function get_optimal_jre_key(path: string): Promise<string | null> {
export async function get_optimal_jre_key(path: string): Promise<JavaVersion | null> {
return await invoke('plugin:profile|profile_get_optimal_jre_key', { path })
}
@@ -611,6 +611,24 @@
"instance.settings.tabs.java": {
"message": "Java and memory"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Custom environment variables"
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Custom Java arguments"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Custom Java installation"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Custom memory allocation"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Enter environmental variables..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Enter Java arguments..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Environment variables"
},
@@ -626,6 +644,9 @@
"instance.settings.tabs.java.java-memory": {
"message": "Memory allocated"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/path/to/java"
},
"instance.settings.tabs.window": {
"message": "Window"
},
+44 -10
View File
@@ -218,7 +218,11 @@
:key="instance.path"
>
<template v-if="Component">
<Suspense :key="instance.path">
<Suspense
:key="instance.path"
@pending="subpagePending = true"
@resolve="subpagePending = false"
>
<component
:is="Component"
:instance="instance"
@@ -228,6 +232,7 @@
:installed="instance.install_stage !== 'installed'"
:is-server-instance="isServerInstance"
:open-settings="() => settingsModal?.show(1)"
v-bind="contentSubpageProps"
@play="updatePlayState"
@stop="() => stopInstance('InstanceSubpage')"
></component>
@@ -295,6 +300,7 @@ import {
ServerPing,
ServerRecentPlays,
ServerRegion,
useLoadingBarToken,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
@@ -312,6 +318,7 @@ import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
import { process_listener, profile_listener } from '@/helpers/events'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_profile_path } from '@/helpers/process'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
@@ -331,6 +338,7 @@ const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const contentSubpageRouteNames = new Set(['Mods', 'ModsFilter'])
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
@@ -341,12 +349,16 @@ window.addEventListener('online', () => {
})
const instance = ref<GameInstance>()
const preloadedContent = ref<InstanceContentData | null>(null)
const playing = ref(false)
const loading = ref(false)
const subpagePending = ref(false)
const stopping = ref(false)
const exportModal = ref<InstanceType<typeof ExportModal>>()
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
useLoadingBarToken(subpagePending)
const isServerInstance = ref(false)
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
const selected = ref<unknown[]>([])
@@ -361,36 +373,55 @@ const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false)
function isContentSubpageRoute(routeName = route.name) {
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
}
async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
preloadedContent.value = null
ping.value = undefined
playersOnline.value = undefined
loadingServerPing.value = false
instance.value = await get(route.params.id as string).catch(handleError)
const nextInstance = await get(route.params.id as string).catch(handleError)
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
let nextIsServerInstance = false
if (!offline.value && instance.value?.linked_data && instance.value.linked_data.project_id) {
const contentPreloadPromise =
nextInstance && isContentSubpageRoute()
? loadInstanceContentData(nextInstance.path, undefined, handleError)
: Promise.resolve(null)
if (!offline.value && nextInstance?.linked_data && nextInstance.linked_data.project_id) {
try {
linkedProjectV3.value = await get_project_v3(
instance.value.linked_data.project_id,
nextLinkedProjectV3 = await get_project_v3(
nextInstance.linked_data.project_id,
'must_revalidate',
)
if (linkedProjectV3.value?.minecraft_server != null) {
isServerInstance.value = true
if (nextLinkedProjectV3?.minecraft_server != null) {
nextIsServerInstance = true
}
} catch (error) {
handleError(error as Error)
}
}
const nextPreloadedContent = await contentPreloadPromise
instance.value = nextInstance ?? undefined
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
preloadedContent.value = nextPreloadedContent
fetchDeferredData()
if (instance.value) {
if (nextInstance) {
queryClient.prefetchQuery({
queryKey: ['worlds', instance.value.path],
queryFn: () => refreshWorlds(instance.value!.path),
queryKey: ['worlds', nextInstance.path],
queryFn: () => refreshWorlds(nextInstance.path),
staleTime: 30_000,
})
}
@@ -448,6 +479,9 @@ const renderMode = computed<'scroll' | 'fixed'>(() =>
route.meta.renderMode === 'fixed' ? 'fixed' : 'scroll',
)
const isFixedRender = computed(() => renderMode.value === 'fixed')
const contentSubpageProps = computed(() =>
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
)
const tabs = computed(() => [
{
@@ -77,7 +77,7 @@ function buildLogList(rawLogs) {
log.filename !== 'latest_stdout.log' &&
log.filename !== 'latest_stdout' &&
log.filename !== 'launcher_log.txt' &&
log.stdout !== '' &&
(log.output == null || log.output !== '') &&
(log.filename.includes('.log') || log.filename.endsWith('.txt')),
)
.map((log) => ({
+400 -109
View File
@@ -13,6 +13,7 @@
:modpack-name="linkedModpackProject?.title"
:modpack-icon-url="linkedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
:switch-version="handleSwitchVersion"
@update:enabled="handleModpackContentToggle"
@@ -87,12 +88,12 @@ import {
ReadyTransition,
useDebugLogger,
useVIntl,
versionChangesGameVersion,
} from '@modrinth/ui'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { open } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
import { useDebounceFn } from '@vueuse/core'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import ExportModal from '@/components/ui/ExportModal.vue'
@@ -100,22 +101,20 @@ import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events.js'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import {
add_project_from_path,
add_project_from_version,
duplicate,
edit,
get,
get_content_items,
get_linked_modpack_content,
get_linked_modpack_info,
list,
remove_project,
toggle_disable_project,
update_managed_modrinth_version,
update_project,
} from '@/helpers/profile'
import { get_categories } from '@/helpers/tags.js'
import type { CacheBehaviour, GameInstance } from '@/helpers/types'
import { highlightModInProfile } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
@@ -160,6 +159,7 @@ const props = defineProps<{
instance: GameInstance
isServerInstance?: boolean
openSettings?: () => void
preloadedContent?: InstanceContentData | null
}>()
const loading = ref(true)
@@ -212,6 +212,7 @@ const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal> | null>()
const modpackUpdateConfirmModal = ref<InstanceType<typeof ConfirmModpackUpdateModal> | null>()
// TODO: Extract content operation and updater modal state into composables; this page currently owns file mutations, dependency installs, busy flags, and version selection flow.
const updatingProject = ref<ContentItem | null>(null)
const updatingProjectVersions = ref<Labrinth.Versions.v2.Version[]>([])
const loadingVersions = ref(false)
@@ -219,6 +220,82 @@ const loadingChangelog = ref(false)
const updatingModpack = ref(false)
const pendingModpackUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const isModpackUpdateDowngrade = ref(false)
const activeContentOperationKeys = ref(new Set<string>())
let activeContentOperationCount = 0
let updateRequestId = 0
const activeUpdateRequestId = ref(0)
function fileNameFromPath(path: string) {
return path.split('/').pop() ?? path
}
function getContentItemId(item: ContentItem | null | undefined) {
return item?.file_path ?? item?.file_name ?? item?.id ?? ''
}
function getContentOperationKeys(item: ContentItem) {
return [getContentItemId(item), item.file_path, item.file_name].filter(
(key): key is string => !!key,
)
}
function hasContentOperation(item: ContentItem) {
const keys = getContentOperationKeys(item)
return keys.some((key) => activeContentOperationKeys.value.has(key))
}
function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) {
item.installing = busy
modpackContentModal.value?.updateItem(originalFileName, {
installing: busy,
disabled: busy,
})
if (item.file_name !== originalFileName) {
modpackContentModal.value?.updateItem(item.file_name, {
installing: busy,
disabled: busy,
})
}
}
function beginContentOperation(item: ContentItem) {
if (hasContentOperation(item)) return null
const keys = getContentOperationKeys(item)
activeContentOperationKeys.value = new Set([...activeContentOperationKeys.value, ...keys])
activeContentOperationCount++
isBulkOperating.value = true
setContentItemBusy(item, true)
return { keys, originalFileName: item.file_name }
}
function finishContentOperation(
item: ContentItem,
operation: { keys: string[]; originalFileName: string },
) {
const nextKeys = new Set(activeContentOperationKeys.value)
for (const key of operation.keys) {
nextKeys.delete(key)
}
activeContentOperationKeys.value = nextKeys
activeContentOperationCount = Math.max(0, activeContentOperationCount - 1)
setContentItemBusy(item, false, operation.originalFileName)
if (activeContentOperationCount === 0) {
isBulkOperating.value = false
}
}
function beginUpdateRequest() {
updateRequestId++
activeUpdateRequestId.value = updateRequestId
return updateRequestId
}
function isActiveUpdateRequest(requestId: number) {
return activeUpdateRequestId.value === requestId
}
async function handleBrowseContent() {
if (!props.instance) return
@@ -265,9 +342,21 @@ async function handleUploadFiles() {
}
async function toggleDisableMod(mod: ContentItem) {
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
try {
mod.file_path = await toggle_disable_project(props.instance.path, mod.file_path!)
const newPath = await toggle_disable_project(props.instance.path, mod.file_path)
const newFileName = fileNameFromPath(newPath)
mod.file_path = newPath
mod.file_name = newFileName
mod.enabled = !mod.enabled
modpackContentModal.value?.updateItem(operation.originalFileName, {
file_path: newPath,
file_name: newFileName,
enabled: mod.enabled,
})
trackEvent('InstanceProjectDisable', {
loader: props.instance.loader,
@@ -279,33 +368,48 @@ async function toggleDisableMod(mod: ContentItem) {
})
} catch (err) {
handleError(err as Error)
} finally {
finishContentOperation(mod, operation)
}
}
const toggleDisableDebounced = useDebounceFn(toggleDisableMod, 20)
const toggleDisableDebounced = toggleDisableMod
async function removeMod(mod: ContentItem) {
await remove_project(props.instance.path, mod.file_path!).catch(handleError)
projects.value = projects.value.filter((x) => mod.file_path !== x.file_path)
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
trackEvent('InstanceProjectRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
try {
const removedPath = mod.file_path
await remove_project(props.instance.path, removedPath)
projects.value = projects.value.filter((x) => removedPath !== x.file_path)
trackEvent('InstanceProjectRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) {
handleError(err as Error)
} finally {
finishContentOperation(mod, operation)
}
}
async function updateProject(mod: ContentItem) {
try {
const newPath = await update_project(props.instance.path, mod.file_path!)
mod.file_path = newPath
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
if (mod.update_version_id) {
const versionData = await get_version(mod.update_version_id, 'must_revalidate').catch(
handleError,
)
try {
const updateVersionId = mod.update_version_id
await update_project(props.instance.path, mod.file_path)
if (updateVersionId) {
const versionData = await get_version(updateVersionId, 'must_revalidate').catch(handleError)
if (versionData) {
const profile = await get(props.instance.path).catch(handleError)
@@ -316,12 +420,6 @@ async function updateProject(mod: ContentItem) {
}
}
mod.has_update = false
if (mod.version && mod.update_version_id) {
mod.version.id = mod.update_version_id
}
mod.update_version_id = null
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
@@ -331,39 +429,65 @@ async function updateProject(mod: ContentItem) {
})
} catch (err) {
handleError(err as Error)
} finally {
await refreshContentState('must_revalidate')
finishContentOperation(mod, operation)
}
}
async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) {
isBulkOperating.value = true
mod.installing = true
if (mod.version) {
mod.version.id = version.id
mod.version.version_number = version.version_number
}
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
const oldPath = mod.file_path
const wasDisabled = mod.enabled === false || oldPath.endsWith('.disabled')
let newPath: string | null = null
let shouldRemoveNewOnError = false
try {
await remove_project(props.instance.path, mod.file_path!)
const newPath = await add_project_from_version(props.instance.path, version.id, 'standalone')
newPath = await add_project_from_version(props.instance.path, version.id, 'update')
shouldRemoveNewOnError = newPath !== oldPath
if (wasDisabled) {
newPath = await toggle_disable_project(props.instance.path, newPath)
}
const profile = await get(props.instance.path).catch(handleError)
if (profile) {
await installVersionDependencies(profile, version, 'update').catch(handleError)
}
mod.file_path = newPath
shouldRemoveNewOnError = false
if (newPath !== oldPath) {
await remove_project(props.instance.path, oldPath)
}
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) {
if (shouldRemoveNewOnError && newPath && newPath !== oldPath) {
await remove_project(props.instance.path, newPath).catch(() => {})
}
handleError(err as Error)
} finally {
mod.installing = false
isBulkOperating.value = false
await initProjects()
await refreshContentState('must_revalidate')
finishContentOperation(mod, operation)
}
}
async function handleUpdate(id: string) {
const item = projects.value.find((p) => p.id === id)
const item = projects.value.find((p) => getContentItemId(p) === id)
if (!item?.has_update || !item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
const itemId = getContentItemId(item)
debug('handleUpdate triggered', {
fileName: item.file_name,
projectType: item.project_type,
@@ -384,12 +508,48 @@ async function handleUpdate(id: string) {
await nextTick()
contentUpdaterModal.value?.show(item.update_version_id ?? undefined)
const initialVersionId = item.update_version_id ?? undefined
debug('handleUpdate: opening content updater modal', {
type: 'content',
initialVersionId,
item: {
id: item.id,
fileName: item.file_name,
projectType: item.project_type,
projectId: item.project.id,
projectTitle: item.project.title,
currentVersionId: item.version.id,
currentVersionNumber: item.version.version_number,
updateVersionId: item.update_version_id,
},
instance: {
path: props.instance.path,
name: props.instance.name,
gameVersion: props.instance.game_version,
loader: props.instance.loader,
linkedData: props.instance.linked_data,
},
modalStateBeforeFetch: {
updatingModpack: updatingModpack.value,
updatingProjectId: updatingProject.value?.id,
updatingProjectVersions: updatingProjectVersions.value.map((version) => ({
id: version.id,
versionNumber: version.version_number,
gameVersions: version.game_versions,
loaders: version.loaders,
datePublished: version.date_published,
})),
},
})
contentUpdaterModal.value?.show(initialVersionId)
const versions = (await get_project_versions(item.project.id).catch((e) => {
return handleError(e)
})) as Labrinth.Versions.v2.Version[] | null
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
return
loadingVersions.value = false
if (!versions) {
@@ -414,6 +574,25 @@ async function handleUpdate(id: string) {
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
const preselectedVersion =
versions.find((version) => version.id === initialVersionId) ?? versions[0] ?? null
debug('handleUpdate: resolved content updater preselection', {
type: 'content',
initialVersionId,
foundInitialVersion: versions.some((version) => version.id === initialVersionId),
preselectedVersion: preselectedVersion
? {
id: preselectedVersion.id,
versionNumber: preselectedVersion.version_number,
gameVersions: preselectedVersion.game_versions,
loaders: preselectedVersion.loaders,
datePublished: preselectedVersion.date_published,
}
: null,
versionCount: versions.length,
currentVersionId: item.version.id,
updateVersionId: item.update_version_id,
})
updatingProjectVersions.value = versions
}
@@ -421,6 +600,9 @@ async function handleUpdate(id: string) {
async function handleSwitchVersion(item: ContentItem) {
if (!item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
const itemId = getContentItemId(item)
updatingModpack.value = false
updatingProject.value = item
updatingProjectVersions.value = []
@@ -435,6 +617,9 @@ async function handleSwitchVersion(item: ContentItem) {
return handleError(e)
})) as Labrinth.Versions.v2.Version[] | null
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
return
loadingVersions.value = false
if (!versions) return
@@ -468,9 +653,28 @@ async function handleModpackContent() {
}
}
async function refreshModpackContentItems(cacheBehaviour?: CacheBehaviour) {
if (!props.instance?.path) return
const contentItems = await get_linked_modpack_content(props.instance.path, cacheBehaviour).catch(
handleError,
)
if (contentItems) {
modpackContentModal.value?.setItems(contentItems)
}
}
async function refreshContentState(cacheBehaviour?: CacheBehaviour) {
await initProjects(cacheBehaviour)
await refreshModpackContentItems(cacheBehaviour)
}
async function handleModpackUpdate() {
if (!props.instance?.linked_data?.project_id) return
const requestId = beginUpdateRequest()
updatingModpack.value = true
updatingProject.value = null
updatingProjectVersions.value = []
@@ -479,14 +683,42 @@ async function handleModpackUpdate() {
await nextTick()
contentUpdaterModal.value?.show(
linkedModpackUpdateVersionId.value ?? props.instance?.linked_data?.version_id ?? undefined,
)
const initialVersionId =
linkedModpackUpdateVersionId.value ?? props.instance?.linked_data?.version_id ?? undefined
debug('handleModpackUpdate: opening modpack updater modal', {
type: 'modpack',
initialVersionId,
linkedModpackUpdateVersionId: linkedModpackUpdateVersionId.value,
linkedModpackProject: linkedModpackProject.value,
linkedModpackVersion: linkedModpackVersion.value,
linkedModpackHasUpdate: linkedModpackHasUpdate.value,
instance: {
path: props.instance.path,
name: props.instance.name,
gameVersion: props.instance.game_version,
loader: props.instance.loader,
linkedData: props.instance.linked_data,
},
modalStateBeforeFetch: {
updatingModpack: updatingModpack.value,
updatingProjectId: updatingProject.value?.id,
updatingProjectVersions: updatingProjectVersions.value.map((version) => ({
id: version.id,
versionNumber: version.version_number,
gameVersions: version.game_versions,
loaders: version.loaders,
datePublished: version.date_published,
})),
},
})
contentUpdaterModal.value?.show(initialVersionId)
const versions = (await get_project_versions(props.instance.linked_data.project_id).catch(
handleError,
)) as Labrinth.Versions.v2.Version[] | null
if (!isActiveUpdateRequest(requestId) || !updatingModpack.value) return
loadingVersions.value = false
if (!versions) return
@@ -494,6 +726,25 @@ async function handleModpackUpdate() {
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
const preselectedVersion =
versions.find((version) => version.id === initialVersionId) ?? versions[0] ?? null
debug('handleModpackUpdate: resolved modpack updater preselection', {
type: 'modpack',
initialVersionId,
foundInitialVersion: versions.some((version) => version.id === initialVersionId),
preselectedVersion: preselectedVersion
? {
id: preselectedVersion.id,
versionNumber: preselectedVersion.version_number,
gameVersions: preselectedVersion.game_versions,
loaders: preselectedVersion.loaders,
datePublished: preselectedVersion.date_published,
}
: null,
versionCount: versions.length,
linkedModpackUpdateVersionId: linkedModpackUpdateVersionId.value,
currentLinkedVersionId: props.instance.linked_data.version_id,
})
updatingProjectVersions.value = versions
}
@@ -502,10 +753,12 @@ async function fetchAndSpliceVersion(
versionId: string,
cacheBehaviour?: Parameters<typeof get_version>[1],
onError?: (err: unknown) => void,
requestId = activeUpdateRequestId.value,
) {
const fullVersion = (await get_version(versionId, cacheBehaviour).catch(
onError ?? (() => null),
)) as Labrinth.Versions.v2.Version | null
if (!isActiveUpdateRequest(requestId)) return
if (!fullVersion) return
const index = updatingProjectVersions.value.findIndex((v) => v.id === versionId)
if (index !== -1) {
@@ -517,17 +770,26 @@ async function fetchAndSpliceVersion(
async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
if (version.changelog != null) return
const requestId = activeUpdateRequestId.value
loadingChangelog.value = true
await fetchAndSpliceVersion(version.id, 'must_revalidate', handleError as (err: unknown) => void)
loadingChangelog.value = false
await fetchAndSpliceVersion(
version.id,
'must_revalidate',
handleError as (err: unknown) => void,
requestId,
)
if (isActiveUpdateRequest(requestId)) {
loadingChangelog.value = false
}
}
async function handleVersionHover(version: Labrinth.Versions.v2.Version) {
if (version.changelog != null) return
await fetchAndSpliceVersion(version.id)
await fetchAndSpliceVersion(version.id, undefined, undefined, activeUpdateRequestId.value)
}
function resetUpdateState() {
activeUpdateRequestId.value = 0
updatingModpack.value = false
updatingProject.value = null
updatingProjectVersions.value = []
@@ -535,13 +797,23 @@ function resetUpdateState() {
loadingChangelog.value = false
}
function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.Version) {
async function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.Version) {
pendingModpackUpdateVersion.value = selectedVersion
const currentVersionId = props.instance?.linked_data?.version_id
const currentVersion = updatingProjectVersions.value.find((v) => v.id === currentVersionId)
isModpackUpdateDowngrade.value = currentVersion
? new Date(selectedVersion.date_published) < new Date(currentVersion.date_published)
: false
const shouldShowWarning =
isModpackUpdateDowngrade.value ||
versionChangesGameVersion(selectedVersion, props.instance.game_version)
if (!shouldShowWarning) {
await handleModpackUpdateConfirm()
return
}
modpackUpdateConfirmModal.value?.show()
}
@@ -551,6 +823,7 @@ async function handleModpackUpdateConfirm() {
const version = pendingModpackUpdateVersion.value
pendingModpackUpdateVersion.value = null
contentUpdaterModal.value?.hide()
isModpackUpdating.value = true
try {
await update_managed_modrinth_version(props.instance.path, version.id)
@@ -574,7 +847,7 @@ async function handleModalUpdate(
pendingModpackUpdateVersion.value = selectedVersion
await handleModpackUpdateConfirm()
} else {
handleModpackUpdateRequest(selectedVersion)
await handleModpackUpdateRequest(selectedVersion)
}
} else if (updatingProject.value) {
const mod = updatingProject.value
@@ -662,51 +935,31 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
async function initProjects(cacheBehaviour?: CacheBehaviour) {
if (!props.instance) return
const [contentItems, modpackInfo, allCategories] = await Promise.all([
get_content_items(props.instance.path, cacheBehaviour).catch(handleError),
get_linked_modpack_info(props.instance.path, cacheBehaviour).catch(handleError),
get_categories().catch(handleError),
])
const contentData = await loadInstanceContentData(
props.instance.path,
cacheBehaviour,
handleError,
)
applyContentData(contentData)
}
if (!contentItems) {
function applyContentData(contentData: InstanceContentData) {
if (contentData.path !== props.instance.path) return false
if (!contentData.contentItems) {
loading.value = false
return
return true
}
projects.value = contentItems
projects.value = contentData.contentItems
if (modpackInfo) {
linkedModpackProject.value = {
...modpackInfo.project,
slug: modpackInfo.project.slug ?? modpackInfo.project.id,
icon_url: modpackInfo.project.icon_url ?? undefined,
}
linkedModpackVersion.value = {
...modpackInfo.version,
date_published: modpackInfo.version.date_published.toString(),
}
linkedModpackOwner.value = modpackInfo.owner
? {
...modpackInfo.owner,
avatar_url: modpackInfo.owner.avatar_url ?? undefined,
}
: null
linkedModpackHasUpdate.value = modpackInfo.has_update
linkedModpackUpdateVersionId.value = modpackInfo.update_version_id
if (allCategories && modpackInfo.project.categories) {
const seen = new Set<string>()
linkedModpackCategories.value = allCategories.filter((cat: { name: string }) => {
if (modpackInfo.project.categories.includes(cat.name) && !seen.has(cat.name)) {
seen.add(cat.name)
return true
}
return false
})
} else {
linkedModpackCategories.value = []
}
if (contentData.modpack) {
linkedModpackProject.value = contentData.modpack.project
linkedModpackVersion.value = contentData.modpack.version
linkedModpackOwner.value = contentData.modpack.owner
linkedModpackCategories.value = contentData.modpack.categories
linkedModpackHasUpdate.value = contentData.modpack.hasUpdate
linkedModpackUpdateVersionId.value = contentData.modpack.updateVersionId
} else {
linkedModpackProject.value = null
linkedModpackVersion.value = null
@@ -717,6 +970,7 @@ async function initProjects(cacheBehaviour?: CacheBehaviour) {
}
loading.value = false
return true
}
provideAppBackup({
@@ -809,8 +1063,9 @@ provideContentManager({
showContentHint,
dismissContentHint,
shareItems: handleShareItems,
getItemId: getContentItemId,
mapToTableItem: (item: ContentItem) => ({
id: item.id,
id: getContentItemId(item),
project: item.project ?? {
id: item.file_name,
slug: null,
@@ -844,10 +1099,22 @@ provideContentManager({
filterPersistKey: props.instance.path,
})
await initProjects()
type UnlistenFn = () => void
const initialContentReady = loadInitialContent()
void initialContentReady.then(restoreModpackContentModalState).catch(handleError)
function loadInitialContent() {
if (props.preloadedContent && applyContentData(props.preloadedContent)) {
return Promise.resolve()
}
return initProjects()
}
async function restoreModpackContentModalState() {
if (!savedModalState) return
// Restore modpack content modal state if returning via back navigation
if (savedModalState) {
const stateToRestore = savedModalState
savedModalState = null
await nextTick()
@@ -860,18 +1127,32 @@ const removeBeforeEach = router.beforeEach(() => {
savedModalState = state ?? null
})
const unlisten = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop' || !props.instance) return
let isUnmounted = false
let unlistenDragDrop: UnlistenFn | null = null
let unlistenProfiles: UnlistenFn | null = null
for (const file of event.payload.paths) {
if (file.endsWith('.mrpack')) continue
await add_project_from_path(props.instance.path, file).catch(handleError)
}
await initProjects()
})
onMounted(() => {
void getCurrentWebview()
.onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop' || !props.instance) return
const unlistenProfiles = await profile_listener(
async (event: { event: string; profile_path_id: string }) => {
for (const file of event.payload.paths) {
if (file.endsWith('.mrpack')) continue
await add_project_from_path(props.instance.path, file).catch(handleError)
}
await initProjects()
})
.then((unlisten) => {
if (isUnmounted) {
unlisten()
return
}
unlistenDragDrop = unlisten
})
.catch(handleError)
void profile_listener(async (event: { event: string; profile_path_id: string }) => {
if (
props.instance &&
event.profile_path_id === props.instance.path &&
@@ -881,8 +1162,17 @@ const unlistenProfiles = await profile_listener(
) {
await initProjects()
}
},
)
})
.then((unlisten) => {
if (isUnmounted) {
unlisten()
return
}
unlistenProfiles = unlisten
})
.catch(handleError)
})
watch(
() => props.instance?.install_stage,
@@ -905,8 +1195,9 @@ watch(
)
onUnmounted(() => {
isUnmounted = true
removeBeforeEach()
unlisten()
unlistenProfiles()
unlistenDragDrop?.()
unlistenProfiles?.()
})
</script>
@@ -8,6 +8,7 @@ export interface InstanceSettingsContext {
offline?: boolean
isMinecraftServer: Ref<boolean>
onUnlinked: () => void
closeModal?: () => void
}
export const [injectInstanceSettings, provideInstanceSettings] =
@@ -17,6 +17,7 @@ import {
writePendingServerContentInstallBaseline,
writeStoredServerInstallQueue,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -208,6 +209,7 @@ export function createServerInstallContent(opts: {
const router = useRouter()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const serverIdQuery = computed(() => readQueryString(route.query.sid))
const worldIdQuery = computed(() => readQueryString(route.query.wid))
@@ -471,6 +473,9 @@ export function createServerInstallContent(opts: {
...serverContentInstallKeys.value,
...result.flushedPlans.map((plan) => plan.projectId),
])
if (result.flushedPlans.length > 0) {
await queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] })
}
return true
} finally {
@@ -491,6 +496,7 @@ export function createServerInstallContent(opts: {
const plans = new Map(queuedServerInstalls.value)
if (sid && wid) {
writeStoredServerInstallQueue(sid, wid, plans)
writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value)
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
void getQueuedInstallPlaceholders(client, plans)
+1
View File
@@ -19,6 +19,7 @@
"types": ["vite/client"],
"paths": {
"@modrinth/api-client": ["../../packages/api-client/src/index.ts"],
"@/*": ["./src/*"]
}
},
+5
View File
@@ -8,6 +8,7 @@ import tauriConf from '../app/tauri.conf.json'
const projectRootDir = resolve(__dirname)
const appLibEnvDir = resolve(projectRootDir, '../../packages/app-lib')
const apiClientSource = resolve(projectRootDir, '../../packages/api-client/src/index.ts')
// Load .env from app-lib manually instead of using Vite's envDir, which would auto-load .env.local and override values
const envFilePath = resolve(appLibEnvDir, '.env')
@@ -37,6 +38,10 @@ export default defineConfig({
},
resolve: {
alias: [
{
find: '@modrinth/api-client',
replacement: apiClientSource,
},
{
find: '@',
replacement: resolve(projectRootDir, 'src'),
+7
View File
@@ -51,6 +51,13 @@ tauri-build = { workspace = true, features = ["codegen"] }
[target.'cfg(target_os = "linux")'.dependencies]
tauri-plugin-updater = { workspace = true, optional = true }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation.workspace = true
core-graphics.workspace = true
objc2-app-kit = { workspace = true, features = [
"NSWindow",
] }
[target.'cfg(windows)'.dependencies]
webview2-com.workspace = true
windows = { workspace = true, features = [
+50 -18
View File
@@ -17,6 +17,8 @@ pub struct AdsState {
}
const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie";
#[cfg(any(windows, target_os = "macos"))]
pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 0.5;
#[cfg(not(target_os = "linux"))]
const ADS_USER_AGENT: &str = concat!(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ",
@@ -92,10 +94,12 @@ fn configure_ads_cookie_settings(
}
}
fn set_webview_visible<R: Runtime>(
webview: &tauri::Webview<R>,
_visible: bool,
) {
fn set_webview_visible<R: Runtime>(webview: &tauri::Webview<R>, visible: bool) {
#[cfg(not(any(windows, target_os = "macos")))]
{
_ = visible;
}
webview
.with_webview(
#[allow(unused_variables)]
@@ -103,11 +107,18 @@ fn set_webview_visible<R: Runtime>(
#[cfg(windows)]
{
let controller = wv.controller();
unsafe { controller.SetIsVisible(_visible) }.ok();
unsafe { controller.SetIsVisible(visible) }.ok();
}
},
)
.ok();
#[cfg(target_os = "macos")]
if visible {
webview.show().ok();
} else {
webview.hide().ok();
}
}
fn set_webview_visible_for_window<R: Runtime>(
@@ -129,27 +140,48 @@ fn set_webview_visible_for_window<R: Runtime>(
set_webview_visible(webview, visible && !is_minimized && !is_occluded);
}
#[cfg(windows)]
#[cfg(any(windows, target_os = "macos"))]
fn compute_ads_webview_occlusion<R: Runtime>(
app: &tauri::AppHandle<R>,
) -> Option<bool> {
let main_window = app.get_window("main")?;
let webviews = app.webviews();
let webview = webviews.get("ads-window")?;
let position = webview.position().ok()?;
let size = webview.size().ok()?;
let hwnd = main_window.hwnd().ok()?;
Some(crate::api::ads_occlusion_windows::is_ads_webview_occluded(
hwnd,
position.x,
position.y,
size.width,
size.height,
))
#[cfg(target_os = "macos")]
{
let position = webview.position().ok()?;
let size = webview.size().ok()?;
Some(
crate::api::ads_occlusion_macos::is_ads_webview_occluded(
&main_window,
position.x,
position.y,
size.width,
size.height,
)
.unwrap_or(false),
)
}
#[cfg(windows)]
{
let position = webview.position().ok()?;
let size = webview.size().ok()?;
let hwnd = main_window.hwnd().ok()?;
Some(crate::api::ads_occlusion_windows::is_ads_webview_occluded(
hwnd,
position.x,
position.y,
size.width,
size.height,
))
}
}
#[cfg(windows)]
#[cfg(any(windows, target_os = "macos"))]
async fn sync_ads_occlusion<R: Runtime>(app: &tauri::AppHandle<R>) {
let Some(occluded) = compute_ads_webview_occlusion(app) else {
return;
@@ -281,7 +313,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
});
}
#[cfg(windows)]
#[cfg(any(windows, target_os = "macos"))]
{
let app_handle = app.clone();
+236
View File
@@ -0,0 +1,236 @@
use std::ptr::NonNull;
use core_foundation::array::CFArray;
use core_foundation::base::{CFType, TCFType};
use core_foundation::dictionary::CFDictionary;
use core_foundation::number::CFNumber;
use core_foundation::string::CFString;
use core_graphics::display::CGDisplay;
use core_graphics::geometry::{CGPoint, CGRect, CGSize};
use core_graphics::window::{
CGWindowID, kCGWindowAlpha, kCGWindowBounds, kCGWindowLayer,
kCGWindowListExcludeDesktopElements,
kCGWindowListOptionOnScreenAboveWindow, kCGWindowListOptionOnScreenOnly,
kCGWindowNumber, kCGWindowOwnerName, kCGWindowOwnerPID,
};
use objc2_app_kit::NSWindow;
pub fn is_ads_webview_occluded<R: tauri::Runtime>(
main_window: &tauri::Window<R>,
ad_x: i32,
ad_y: i32,
ad_width: u32,
ad_height: u32,
) -> Option<bool> {
let scale_factor = main_window.scale_factor().ok()?;
let maybe_ns_window = main_window.ns_window().ok()?;
let ns_window = NonNull::new(maybe_ns_window)?.cast::<NSWindow>();
let ns_window = unsafe { ns_window.as_ref() };
let window_number = ns_window.windowNumber();
let main_window_id = if window_number > 0 {
window_number as CGWindowID
} else {
return None;
};
let window_infos = CGDisplay::window_list_info(
kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
None,
)?;
let window_infos = unsafe {
CFArray::<CFDictionary<CFString, CFType>>::wrap_under_get_rule(
window_infos.as_concrete_TypeRef(),
)
};
let main_window_rect = window_infos
.iter()
.find(|window_info| window_id(window_info) == Some(main_window_id))
.and_then(|window_info| window_rect(&window_info))?;
let ad_rect = ad_rect_from_main_window(
&main_window_rect,
ad_x,
ad_y,
ad_width,
ad_height,
scale_factor,
);
if is_empty_rect(&ad_rect) {
return None;
}
let ad_area = rect_area(&ad_rect);
if ad_area == 0.0 {
return None;
}
let app_process_id = std::process::id() as i32;
let windows_above_main = CGDisplay::window_list_info(
kCGWindowListOptionOnScreenAboveWindow
| kCGWindowListExcludeDesktopElements,
Some(main_window_id),
)?;
let windows_above_main = unsafe {
CFArray::<CFDictionary<CFString, CFType>>::wrap_under_get_rule(
windows_above_main.as_concrete_TypeRef(),
)
};
let mut occluded_area = 0.0;
for window_info in windows_above_main.iter() {
if window_id(&window_info) == Some(main_window_id) {
continue;
}
if window_process_id(&window_info) == Some(app_process_id) {
continue;
}
let owner_name = window_owner_name(&window_info);
if owner_name.as_deref().is_some_and(is_system_window_owner) {
continue;
}
let layer = window_layer(&window_info);
if layer != Some(0) {
continue;
}
let alpha = window_alpha(&window_info);
if alpha.is_some_and(|alpha| alpha <= 0.0) {
continue;
}
let Some(rect) = window_rect(&window_info) else {
continue;
};
let Some(intersection) = intersect_rects(&ad_rect, &rect) else {
continue;
};
occluded_area += rect_area(&intersection);
let occluded_ratio = occluded_area / ad_area;
if occluded_ratio >= super::ads::OCCLUDED_AREA_THRESHOLD {
return Some(true);
}
}
Some(false)
}
fn ad_rect_from_main_window(
main_window_rect: &CGRect,
x: i32,
y: i32,
width: u32,
height: u32,
scale_factor: f64,
) -> CGRect {
let scale_factor = if scale_factor > 0.0 {
scale_factor
} else {
1.0
};
CGRect::new(
&CGPoint::new(
main_window_rect.origin.x + x as f64 / scale_factor,
main_window_rect.origin.y + y as f64 / scale_factor,
),
&CGSize::new(width as f64 / scale_factor, height as f64 / scale_factor),
)
}
fn window_id(
window_info: &CFDictionary<CFString, CFType>,
) -> Option<CGWindowID> {
number_value(window_info, unsafe { kCGWindowNumber })
.and_then(|value| u32::try_from(value).ok())
}
fn window_process_id(
window_info: &CFDictionary<CFString, CFType>,
) -> Option<i32> {
number_value(window_info, unsafe { kCGWindowOwnerPID })
.and_then(|value| i32::try_from(value).ok())
}
fn window_layer(window_info: &CFDictionary<CFString, CFType>) -> Option<i32> {
number_value(window_info, unsafe { kCGWindowLayer })
.and_then(|value| i32::try_from(value).ok())
}
fn window_alpha(window_info: &CFDictionary<CFString, CFType>) -> Option<f64> {
let key = unsafe { CFString::wrap_under_get_rule(kCGWindowAlpha) };
window_info.find(&key)?.downcast::<CFNumber>()?.to_f64()
}
fn window_owner_name(
window_info: &CFDictionary<CFString, CFType>,
) -> Option<String> {
let key = unsafe { CFString::wrap_under_get_rule(kCGWindowOwnerName) };
Some(window_info.find(&key)?.downcast::<CFString>()?.to_string())
}
fn is_system_window_owner(owner_name: &str) -> bool {
matches!(owner_name, "WindowManager")
}
fn number_value(
window_info: &CFDictionary<CFString, CFType>,
key: core_foundation::string::CFStringRef,
) -> Option<i64> {
let key = unsafe { CFString::wrap_under_get_rule(key) };
window_info.find(&key)?.downcast::<CFNumber>()?.to_i64()
}
fn window_rect(window_info: &CFDictionary<CFString, CFType>) -> Option<CGRect> {
let key = unsafe { CFString::wrap_under_get_rule(kCGWindowBounds) };
let bounds = window_info.find(&key)?.downcast::<CFDictionary>()?;
let rect = CGRect::from_dict_representation(&bounds)?;
if is_empty_rect(&rect) {
None
} else {
Some(rect)
}
}
fn is_empty_rect(rect: &CGRect) -> bool {
rect.size.width <= 0.0 || rect.size.height <= 0.0
}
fn rect_area(rect: &CGRect) -> f64 {
if is_empty_rect(rect) {
return 0.0;
}
rect.size.width * rect.size.height
}
fn intersect_rects(a: &CGRect, b: &CGRect) -> Option<CGRect> {
let left = a.origin.x.max(b.origin.x);
let top = a.origin.y.max(b.origin.y);
let right = (a.origin.x + a.size.width).min(b.origin.x + b.size.width);
let bottom = (a.origin.y + a.size.height).min(b.origin.y + b.size.height);
let rect = CGRect::new(
&CGPoint::new(left, top),
&CGSize::new(right - left, bottom - top),
);
if is_empty_rect(&rect) {
None
} else {
Some(rect)
}
}
+1 -3
View File
@@ -8,8 +8,6 @@ use windows::Win32::UI::WindowsAndMessaging::{
GetWindowThreadProcessId, IsIconic, IsWindowVisible,
};
const OCCLUDED_AREA_THRESHOLD: f64 = 1.0;
pub fn is_ads_webview_occluded(
main_hwnd: HWND,
x: i32,
@@ -63,7 +61,7 @@ pub fn is_ads_webview_occluded(
occluded_area.saturating_add(rect_area(&intersection));
if (occluded_area as f64 / ad_area as f64)
>= OCCLUDED_AREA_THRESHOLD
>= super::ads::OCCLUDED_AREA_THRESHOLD
{
return true;
}
+2
View File
@@ -18,6 +18,8 @@ pub mod tags;
pub mod utils;
pub mod ads;
#[cfg(target_os = "macos")]
mod ads_occlusion_macos;
#[cfg(windows)]
mod ads_occlusion_windows;
pub mod cache;
+12 -1
View File
@@ -1,10 +1,15 @@
import { GenericModrinthClient, type Labrinth } from '@modrinth/api-client'
import serverSidedVue from '@vitejs/plugin-vue'
import fs from 'fs/promises'
import { defineNuxtConfig } from 'nuxt/config'
import { fileURLToPath } from 'url'
import svgLoader from 'vite-svg-loader'
import { GenericModrinthClient, type Labrinth } from '../../packages/api-client/src/index.ts'
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
const API_CLIENT_SOURCE = fileURLToPath(
new URL('../../packages/api-client/src/index.ts', import.meta.url),
)
const preloadedFonts = [
'inter/Inter-Regular.woff2',
@@ -24,6 +29,9 @@ const STAGING_MODRINTH_URL = 'https://staging.modrinth.com'
export default defineNuxtConfig({
srcDir: 'src/',
alias: {
'@modrinth/api-client': API_CLIENT_SOURCE,
},
app: {
head: {
htmlAttrs: {
@@ -82,6 +90,9 @@ export default defineNuxtConfig({
},
cacheDir: '../../node_modules/.vite/apps/knossos',
resolve: {
alias: {
'@modrinth/api-client': API_CLIENT_SOURCE,
},
dedupe: ['vue'],
},
plugins: [
-1
View File
@@ -38,7 +38,6 @@
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"@intercom/messenger-js-sdk": "^0.0.14",
"@ltd/j-toml": "^1.38.0",
"@modrinth/api-client": "workspace:*",
"@modrinth/assets": "workspace:*",
@@ -46,7 +46,7 @@
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version changlog </span>
<span class="font-semibold text-contrast"> Version changelog </span>
<div class="w-full">
<MarkdownEditor
@@ -131,10 +131,9 @@
class="h-10"
/>
<StyledInput
<DatePicker
v-else-if="field.type === 'date'"
v-model="formData[field.name]"
type="date"
wrapper-class="w-full"
/>
@@ -230,6 +229,7 @@ import {
Admonition,
Checkbox,
Combobox,
DatePicker,
defineMessages,
financialMessages,
formFieldLabels,
@@ -95,11 +95,11 @@
<span class="text-red">*</span>
</span>
</label>
<StyledInput
<DatePicker
v-model="formData.dateOfBirth"
type="date"
:max="maxDate"
:max-date="maxDate"
autocomplete="bday"
placeholder="Select date of birth"
wrapper-class="w-full"
/>
</div>
@@ -213,6 +213,7 @@
import {
Chips,
Combobox,
DatePicker,
defineMessages,
formFieldLabels,
formFieldPlaceholders,
@@ -233,7 +233,7 @@ const visibleNags = computed<Nag[]>(() => {
link: {
path: 'moderation',
title: messages.visitModerationPage,
shouldShow: () => props.routeName !== 'type-id-moderation',
shouldShow: () => props.routeName !== 'type-project-moderation',
},
})
}
@@ -48,6 +48,16 @@
<span class="text-sm text-secondary">Requesting</span>
<Badge :type="queueEntry.project.requested_status" class="text-sm" />
</div>
<div
v-if="showExternalDependencies"
v-tooltip="'External dependencies'"
class="flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
>
<FileIcon aria-hidden="true" class="size-4 text-secondary" />
<span class="text-sm font-medium text-secondary">
{{ queueEntry.external_dependencies_count }}
</span>
</div>
</div>
<div v-if="queueEntry.ownership?.kind === 'user'">
<NuxtLink
@@ -119,7 +129,7 @@
</template>
<script setup lang="ts">
import { ClipboardCopyIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
import { ClipboardCopyIcon, FileIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
import {
Avatar,
Badge,
@@ -151,6 +161,7 @@ const formatDateTimeFull = useFormatDateTime({
const props = defineProps<{
queueEntry: ModerationProject
showExternalDependencies?: boolean
}>()
const emit = defineEmits<{
@@ -701,11 +701,11 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
// Quick re-check if close to expiry (last 5 seconds of TTL)
if (now - next.validatedAt > PREFETCH_STALE_MS - 5000) {
const recheck = await moderationQueue.checkLock(next.projectId)
if (recheck.locked && !recheck.expired) {
// Project got locked, remove from queue and try next
const recheckResults = await batchCheckQueueCandidates([next.projectId])
const recheck = recheckResults.get(next.projectId)
if (!isEligibleQueueCandidate(recheck)) {
prefetchQueue.value.shift()
return navigateToNextUnlockedProject() // Recurse to try next
return navigateToNextUnlockedProject()
}
}
@@ -717,33 +717,14 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
if (next.skippedIds.length > 0) {
addNotification({
title: 'Skipped locked projects',
text: `Skipped ${next.skippedIds.length} project(s) being moderated by others.`,
type: 'info',
})
}
notifySkippedQueueProjects(next.skippedIds.length)
// Trigger prefetch replenishment in background (don't await)
maintainPrefetchQueue()
// Navigate to canonical URL if we have metadata (avoids middleware redirect)
if (next.slug && next.projectType) {
const urlType = getProjectTypeForUrlShorthand(next.projectType, [], tags.value)
navigateTo({
path: `/${urlType}/${next.slug}`,
state: { showChecklist: true },
})
} else {
// Fallback: use project ID (will trigger middleware redirect)
navigateTo({
name: 'type-id',
params: { type: 'project', id: next.projectId },
state: { showChecklist: true },
})
}
navigateToQueueProject(
{ slug: next.slug, projectType: next.projectType, locked: false, isProcessing: true },
next.projectId,
)
return true
}
@@ -869,53 +850,107 @@ function reviewAnyway() {
maintainPrefetchQueue()
}
// Batch check locks and fetch project metadata in parallel
interface LockCheckResult {
// Batch check locks, processing status, and fetch project metadata in parallel
interface QueueCandidateCheck {
locked: boolean
expired?: boolean
isOwnLock?: boolean
slug?: string
projectType?: string
status?: string
isProcessing: boolean
}
async function batchCheckLocksWithMetadata(
function isEligibleQueueCandidate(result: QueueCandidateCheck | undefined): boolean {
if (!result?.isProcessing) return false
return !result.locked || !!result.expired || !!result.isOwnLock
}
function notifySkippedQueueProjects(count: number) {
if (count <= 0) return
addNotification({
title: 'Skipped projects',
text: `Skipped ${count} project(s) already moderated or locked by others.`,
type: 'info',
})
}
function navigateToQueueProject(result: QueueCandidateCheck, projectId: string) {
if (result.slug && result.projectType) {
const urlType = getProjectTypeForUrlShorthand(result.projectType, [], tags.value)
navigateTo({
path: `/${urlType}/${result.slug}`,
state: { showChecklist: true },
})
} else {
navigateTo({
name: 'type-project',
params: { type: 'project', project: projectId },
state: { showChecklist: true },
})
}
}
async function batchCheckQueueCandidates(
projectIds: string[],
): Promise<Map<string, LockCheckResult>> {
const results = new Map<string, LockCheckResult>()
): Promise<Map<string, QueueCandidateCheck>> {
const results = new Map<string, QueueCandidateCheck>()
// Check locks and fetch minimal project data in parallel
const checks = await Promise.allSettled(
projectIds.map(async (id) => {
// Parallel: check lock AND fetch project metadata
const [lockResponse, projectData] = await Promise.all([
moderationQueue.checkLock(id),
useBaseFetch(`project/${id}`, { method: 'GET' }).catch(() => null),
])
const status = (projectData as { status?: string } | null)?.status
return {
id,
locked: lockResponse.locked,
expired: lockResponse.expired,
isOwnLock: lockResponse.is_own_lock,
slug: (projectData as { slug?: string })?.slug,
projectType: (projectData as { project_type?: string })?.project_type,
slug: (projectData as { slug?: string } | null)?.slug,
projectType: (projectData as { project_type?: string } | null)?.project_type,
status,
isProcessing: projectData === null ? true : status === 'processing',
}
}),
)
// Use forEach with index to avoid indexOf bug on PromiseSettledResult
checks.forEach((result, index) => {
if (result.status === 'fulfilled') {
results.set(result.value.id, result.value)
} else {
// On error, mark as needing fallback (no metadata)
results.set(projectIds[index], { locked: false })
results.set(projectIds[index], { locked: false, isProcessing: true })
}
})
return results
}
async function findNextEligibleQueueProject(candidateIds: string[]) {
const skippedIds: string[] = []
let checkedCount = 0
while (checkedCount < candidateIds.length) {
const batch = candidateIds.slice(checkedCount, checkedCount + PREFETCH_BATCH_SIZE)
checkedCount += batch.length
const results = await batchCheckQueueCandidates(batch)
for (const id of batch) {
const result = results.get(id)
if (isEligibleQueueCandidate(result)) {
return { projectId: id, result: result!, skippedIds: [...skippedIds] }
}
skippedIds.push(id)
}
}
return null
}
// Maintain a queue of prefetched unlocked projects for instant navigation
async function maintainPrefetchQueue() {
if (isPrefetching.value) return
@@ -947,13 +982,10 @@ async function maintainPrefetchQueue() {
const remainingItems =
currentIndex >= 0 ? queueItems.slice(currentIndex + 1) : queueItems.slice(1)
const candidateIds = remainingItems
.filter((id) => !prefetchedIds.has(id))
.slice(0, PREFETCH_BATCH_SIZE * 2) // Check up to 10 candidates
const candidateIds = remainingItems.filter((id) => !prefetchedIds.has(id))
if (candidateIds.length === 0) return
// 5. Batch check locks AND fetch metadata in parallel
const skippedIds: string[] = []
let checkedCount = 0
@@ -964,31 +996,18 @@ async function maintainPrefetchQueue() {
const batch = candidateIds.slice(checkedCount, checkedCount + PREFETCH_BATCH_SIZE)
checkedCount += batch.length
const results = await batchCheckLocksWithMetadata(batch)
const results = await batchCheckQueueCandidates(batch)
for (const id of batch) {
const result = results.get(id)
// Treat as unlocked if: not locked, OR expired, OR it's our own lock
if (!result?.locked || result?.expired || result?.isOwnLock) {
// Found unlocked project with metadata
if (result?.slug && result?.projectType) {
prefetchQueue.value.push({
projectId: id,
slug: result.slug,
projectType: result.projectType,
validatedAt: Date.now(),
skippedIds: [...skippedIds],
})
} else {
// No metadata - still add but will need fallback navigation
prefetchQueue.value.push({
projectId: id,
slug: '', // Empty = use fallback
projectType: '',
validatedAt: Date.now(),
skippedIds: [...skippedIds],
})
}
if (isEligibleQueueCandidate(result)) {
prefetchQueue.value.push({
projectId: id,
slug: result?.slug ?? '',
projectType: result?.projectType ?? '',
validatedAt: Date.now(),
skippedIds: [...skippedIds],
})
if (prefetchQueue.value.length >= PREFETCH_TARGET_COUNT) break
} else {
@@ -1004,8 +1023,6 @@ async function maintainPrefetchQueue() {
// Debounced prefetch to prevent spam from rapid stage changes
const debouncedPrefetch = useDebounceFn(maintainPrefetchQueue, 300)
const MAX_SKIP_ATTEMPTS = 10
async function skipToNextProject() {
// Skip the current project
const currentProjectId = projectV2.value?.id
@@ -1029,60 +1046,28 @@ async function skipToNextProject() {
debug('[skipToNextProject] No prefetch, entering fallback with batch checking')
// Fallback: batch check remaining projects with metadata (excluding current)
const remainingIds: string[] = []
const queueItems = moderationQueue.currentQueue.items
// Build list of remaining projects, excluding current
for (const id of queueItems) {
if (id === currentProjectId) continue
if (remainingIds.length >= MAX_SKIP_ATTEMPTS) break
remainingIds.push(id)
}
const remainingIds = moderationQueue.currentQueue.items.filter((id) => id !== currentProjectId)
if (remainingIds.length > 0) {
const results = await batchCheckLocksWithMetadata(remainingIds)
const next = await findNextEligibleQueueProject(remainingIds)
let skippedCount = 0
for (const id of remainingIds) {
const result = results.get(id)
// Treat as unlocked if: not locked, OR expired, OR it's our own lock
if (!result?.locked || result?.expired || result?.isOwnLock) {
// Found unlocked - skip the locked ones before it
if (skippedCount > 0) {
addNotification({
title: 'Skipped locked projects',
text: `Skipped ${skippedCount} project(s) being moderated by others.`,
type: 'info',
})
}
// Navigate to canonical URL if we have metadata
if (result?.slug && result?.projectType) {
const urlType = getProjectTypeForUrlShorthand(result.projectType, [], tags.value)
navigateTo({
path: `/${urlType}/${result.slug}`,
state: { showChecklist: true },
})
} else {
// Fallback: use project ID
navigateTo({
name: 'type-id',
params: { type: 'project', id },
state: { showChecklist: true },
})
}
return
}
await moderationQueue.completeCurrentProject(id, 'skipped')
skippedCount++
if (next) {
await Promise.all(
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
notifySkippedQueueProjects(next.skippedIds.length)
navigateToQueueProject(next.result, next.projectId)
return
}
// All checked were locked
debug('[skipToNextProject] All projects were locked, skippedCount:', skippedCount)
await Promise.all(
remainingIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
debug('[skipToNextProject] No eligible projects in queue')
addNotification({
title: 'All projects locked',
text: 'All remaining projects are currently being moderated by others.',
title: 'No projects available',
text: 'All remaining projects are already moderated or locked by others.',
type: 'warning',
})
}
@@ -1298,32 +1283,16 @@ onMounted(async () => {
document.addEventListener('visibilitychange', handleVisibilityChange)
notifications.setNotificationLocation('left')
// Check if project has already been reviewed (not in processing status)
if (projectV2.value.status !== 'processing') {
alreadyReviewed.value = true
return
}
// Try to acquire lock
const result = await moderationQueue.acquireLock(projectV2.value.id)
if (result.success) {
handleLockAcquired()
} else if (result.locked_by) {
// Actually locked by another moderator
// In queue mode with more projects - auto-skip to next project
if (moderationQueue.isQueueMode && moderationQueue.queueLength > 1) {
addNotification({
title: 'Project locked',
text: `Skipped project locked by @${result.locked_by.username}.`,
type: 'info',
})
// skipToNextProject already calls completeCurrentProject
await skipToNextProject()
return
}
// Single project mode or last in queue - show locked UI
lockStatus.value = {
locked: true,
lockedBy: result.locked_by,
@@ -2085,72 +2054,36 @@ async function endChecklist(status?: string) {
})
}
} else {
// Use prefetched data if available for instant navigation
if (!(await navigateToNextUnlockedProject())) {
// Fallback: batch check remaining projects with metadata
const remainingIds: string[] = []
const currentProjectId = projectV2.value?.id
const queueItems = moderationQueue.currentQueue.items
const remainingIds = moderationQueue.currentQueue.items.filter(
(id) => id !== currentProjectId,
)
// Build list of remaining projects, excluding current
for (const id of queueItems) {
if (id === currentProjectId) continue
if (remainingIds.length >= MAX_SKIP_ATTEMPTS) break
remainingIds.push(id)
}
let foundUnlocked = false
let foundEligible = false
if (remainingIds.length > 0) {
const results = await batchCheckLocksWithMetadata(remainingIds)
const next = await findNextEligibleQueueProject(remainingIds)
let skippedCount = 0
for (const id of remainingIds) {
const result = results.get(id)
// Treat as unlocked if: not locked, OR expired, OR it's our own lock
if (!result?.locked || result?.expired || result?.isOwnLock) {
// Found unlocked - skip the locked ones before it
if (skippedCount > 0) {
addNotification({
title: 'Skipped locked projects',
text: `Skipped ${skippedCount} project(s) being moderated by others.`,
type: 'info',
})
}
// Navigate to canonical URL if we have metadata
if (result?.slug && result?.projectType) {
const urlType = getProjectTypeForUrlShorthand(result.projectType, [], tags.value)
navigateTo({
path: `/${urlType}/${result.slug}`,
state: { showChecklist: true },
})
} else {
// Fallback: use project ID
navigateTo({
name: 'type-id',
params: { type: 'project', id },
state: { showChecklist: true },
})
}
foundUnlocked = true
break
}
await moderationQueue.completeCurrentProject(id, 'skipped')
skippedCount++
}
// If no unlocked projects found, show notification
if (!foundUnlocked && skippedCount > 0) {
if (next) {
await Promise.all(
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
notifySkippedQueueProjects(next.skippedIds.length)
navigateToQueueProject(next.result, next.projectId)
foundEligible = true
} else {
await Promise.all(
remainingIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
addNotification({
title: 'All projects locked',
text: 'All remaining projects are currently being moderated by others.',
title: 'No projects available',
text: 'All remaining projects are already moderated or locked by others.',
type: 'warning',
})
}
}
// If no unlocked projects found, go back to moderation queue
if (!foundUnlocked) {
if (!foundEligible) {
await navigateTo({
name: 'moderation',
})
+38 -19
View File
@@ -1,3 +1,10 @@
function normalizeAuthToken(value) {
if (typeof value === 'string') {
return value
}
return ''
}
export const useAuth = async (oldToken = null) => {
const auth = useState('auth', () => ({
user: null,
@@ -32,27 +39,35 @@ export const initAuth = async (oldToken = null) => {
})
if (oldToken) {
authCookie.value = oldToken
const normalized = normalizeAuthToken(oldToken)
if (normalized) {
authCookie.value = normalized
}
}
if (route.query.code && !route.fullPath.includes('new_account=true')) {
authCookie.value = route.query.code
const oauthCode = normalizeAuthToken(route.query.code)
if (oauthCode && !route.fullPath.includes('new_account=true')) {
authCookie.value = oauthCode
}
if (route.fullPath.includes('new_account=true') && route.path !== '/auth/welcome') {
const redirect = route.path.startsWith('/auth/') ? null : route.fullPath
await navigateTo(
`/auth/welcome?authToken=${route.query.code}${
`/auth/welcome?authToken=${oauthCode}${
redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''
}`,
)
}
if (authCookie.value) {
auth.token = authCookie.value
const tokenStr = normalizeAuthToken(authCookie.value)
if (!auth.token || !auth.token.startsWith('mra_')) {
if (authCookie.value != null && tokenStr === '') {
authCookie.value = null
} else if (tokenStr) {
auth.token = tokenStr
if (!auth.token.startsWith('mra_')) {
return auth
}
@@ -71,7 +86,7 @@ export const initAuth = async (oldToken = null) => {
}
}
if (!auth.user && auth.token) {
if (!auth.user && auth.token && typeof auth.token === 'string') {
try {
const session = await useBaseFetch(
'session/refresh',
@@ -84,18 +99,22 @@ export const initAuth = async (oldToken = null) => {
true,
)
auth.token = session.session
authCookie.value = auth.token
auth.user = await useBaseFetch(
'user',
{
headers: {
Authorization: auth.token,
auth.token = normalizeAuthToken(session.session)
if (auth.token) {
authCookie.value = auth.token
auth.user = await useBaseFetch(
'user',
{
headers: {
Authorization: auth.token,
},
},
},
true,
)
true,
)
} else {
authCookie.value = null
auth.token = ''
}
} catch {
authCookie.value = null
}
+4 -1
View File
@@ -199,17 +199,20 @@ export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipO
export interface ProjectWithOwnership {
ownership: ModerationOwnership
external_dependencies_count: number
[key: string]: any
}
export interface ModerationProject {
project: any
ownership: ModerationOwnership | null
external_dependencies_count: number
}
export function toModerationProjects(projects: ProjectWithOwnership[]): ModerationProject[] {
return projects.map(({ ownership, ...project }) => ({
return projects.map(({ ownership, external_dependencies_count, ...project }) => ({
project,
ownership: ownership ?? null,
external_dependencies_count: external_dependencies_count,
}))
}
+30 -1
View File
@@ -761,9 +761,13 @@ import {
commonMessages,
commonProjectTypeCategoryMessages,
commonSettingsMessages,
createHostingIntercomIdentityKey,
defineMessages,
injectModrinthClient,
injectPageContext,
OverflowMenu,
providePageContext,
useHostingIntercom,
useVIntl,
} from '@modrinth/ui'
import TeleportOverflowMenu from '@modrinth/ui/src/components/base/TeleportOverflowMenu.vue'
@@ -808,6 +812,25 @@ const router = useNativeRouter()
const signInRouteObj = computed(() => getSignInRouteObj(route))
const link = config.public.siteUrl + route.path.replace(/\/+$/, '')
const client = injectModrinthClient()
const pageContext = injectPageContext()
const hostingIntercomActive = computed(() => route.path.startsWith('/hosting') && !!auth.value.user)
const hostingIntercomServerId = computed(() => {
const rawId = route.params.id
return Array.isArray(rawId) ? rawId[0] : rawId
})
const hostingIntercom = useHostingIntercom({
enabled: hostingIntercomActive,
appId: computed(() => config.public.intercomAppId),
fetchToken: fetchIntercomToken,
identityKey: computed(() =>
createHostingIntercomIdentityKey(auth.value.user, hostingIntercomServerId.value),
),
})
providePageContext({
...pageContext,
intercomBubble: hostingIntercom.intercomBubble,
})
const { data: payoutBalance } = useQuery({
queryKey: ['payout', 'balance'],
@@ -836,6 +859,12 @@ const showTinMismatchBanner = computed(() => {
const basePopoutId = useId()
async function fetchIntercomToken() {
return $fetch('/api/intercom/messenger-jwt', {
query: hostingIntercomServerId.value ? { server_id: hostingIntercomServerId.value } : {},
})
}
const navMenuMessages = defineMessages({
home: {
id: 'layout.nav.home',
@@ -1148,7 +1177,7 @@ const isDiscovering = computed(
)
const isDiscoveringSubpage = computed(
() => route.name && route.name.startsWith('type-id') && !route.query.sid,
() => route.name && route.name.startsWith('type-project') && !route.query.sid,
)
const isRussia = computed(() => country.value === 'ru')
+1 -1
View File
@@ -1536,7 +1536,7 @@
"message": "Where are Modrinth Hosting servers located? Can I choose a region?"
},
"hosting-marketing.faq.location.answer": {
"message": "We have servers available in North America, Europe, and Southeast Asia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support."
"message": "We have servers available across North America, Europe, and Southeast Asia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support."
},
"hosting-marketing.faq.versions-loaders": {
"message": "What Minecraft versions and loaders can be used?"
@@ -20,8 +20,13 @@ const PROJECT_TYPES = [
export default defineNuxtRouteMiddleware(async (to) => {
// Only run this middleware on the server - it relies on server-only runtime config
if (import.meta.client) return
const routeProjectParam = to.params.project
const projectId = Array.isArray(routeProjectParam) ? routeProjectParam[0] : routeProjectParam
const routeType = Array.isArray(to.params.type) ? to.params.type[0] : to.params.type
// Only handle project routes
if (!to.params.id || !PROJECT_TYPES.includes(to.params.type as string)) {
if (!projectId || !routeType || !PROJECT_TYPES.includes(routeType)) {
return
}
@@ -29,7 +34,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
const authToken = useCookie('auth-token')
const client = useServerModrinthClient({ authToken: authToken.value || undefined })
const tags = useGeneratedState()
const projectId = to.params.id as string
try {
// Fetch v2 and v3 in parallel — cache both for the page's useQuery calls
@@ -485,7 +485,7 @@
v-if="!isServerProject"
size="large"
:color="
(auth.user && currentMember) || route.name === 'type-id-version-version'
(auth.user && currentMember) || route.name === 'type-project-version-version'
? `standard`
: `brand`
"
@@ -507,7 +507,7 @@
v-else
size="large"
:color="
(auth.user && currentMember) || route.name === 'type-id-version-version'
(auth.user && currentMember) || route.name === 'type-project-version-version'
? `standard`
: `brand`
"
@@ -529,7 +529,7 @@
size="large"
circular
:color="
route.name === 'type-id-version-version' || (auth.user && currentMember)
route.name === 'type-project-version-version' || (auth.user && currentMember)
? `standard`
: `brand`
"
@@ -547,7 +547,7 @@
size="large"
circular
:color="
route.name === 'type-id-version-version' || (auth.user && currentMember)
route.name === 'type-project-version-version' || (auth.user && currentMember)
? `standard`
: `brand`
"
@@ -1136,7 +1136,7 @@ import { useModerationQueue } from '~/services/moderation-queue.ts'
import { getReportPath, reportProject } from '~/utils/report-helpers.ts'
definePageMeta({
key: (route) => `${route.params.id}`,
key: (route) => `${route.params.project}`,
})
const data = useNuxtApp()
@@ -1151,6 +1151,9 @@ const { addNotification } = notifications
const auth = await useAuth()
const user = await useUser()
// Route param for initial lookup (middleware caches by both slug and ID)
const routeProjectId = ref(useRouteId('project'))
const { createProjectDownloadUrl } = useCdnDownloadContext()
const downloadReason = ref('standalone')
@@ -1637,7 +1640,7 @@ const collections = computed(() =>
)
if (
!route.params.id ||
!routeProjectId.value ||
!(
tags.value.projectTypes.find((x) => x.id === route.params.type) ||
route.params.type === 'project'
@@ -1650,9 +1653,6 @@ if (
})
}
// Route param for initial lookup (middleware caches by both slug and ID)
const routeProjectId = computed(() => route.params.id)
// Use DI client for TanStack Query
const client = injectModrinthClient()
const queryClient = useQueryClient()
@@ -1870,7 +1870,7 @@ const { data: organizationRaw } = useQuery({
// Return null when the project no longer belongs to an organization.
const organization = computed(() => (projectRaw.value?.organization ? organizationRaw.value : null))
const isSettings = computed(() => route.name.startsWith('type-id-settings'))
const isSettings = computed(() => route.name.startsWith('type-project-settings'))
// Transform versionsV3 to be same shape as versionsV2 for compatibility in project pages
const versionsRaw = computed(() => {
@@ -1911,27 +1911,6 @@ function loadDependencies() {
// This allows showing/hiding UI elements without loading full version data
const hasVersions = computed(() => (project.value?.versions?.length ?? 0) > 0)
async function updateProjectRoute() {
if (
project.value &&
route.params.id !== project.value.slug &&
!flags.value.disablePrettyProjectUrlRedirects
) {
await navigateTo(
{
name: route.name,
params: {
...route.params,
id: project.value.slug,
},
query: route.query,
hash: route.hash,
},
{ replace: true },
)
}
}
async function invalidateProject() {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', routeProjectId.value] })
@@ -1947,12 +1926,27 @@ async function invalidateProject() {
const patchProjectMutation = useMutation({
mutationFn: async ({ projectId, data }) => {
await client.labrinth.projects_v2.edit(projectId, data)
if (data.slug !== undefined && data.slug !== route.params.project) {
routeProjectId.value = data.slug
await navigateTo(
{
name: route.name,
params: {
type: route.params.type,
project: data.slug,
},
query: route.query,
hash: route.hash,
},
{ replace: true },
)
}
return data
},
onMutate: async ({ projectId, data }) => {
// Cancel outgoing refetches for both slug-based and ID-based cache keys
// The query may be keyed by slug (routeProjectId) but we also have the actual UUID (projectId)
// The query may be keyed by slug (routeProjectId.value) but we also have the actual UUID (projectId)
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== projectId) {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
@@ -2332,9 +2326,8 @@ watch(
const projectTypeDisplay = computed(() => {
if (!project.value) return ''
return formatProjectType(
data.$getProjectTypeForDisplay(project.value.project_type, project.value.loaders),
)
const projectType = isServerProject.value ? 'minecraft_java_server' : project.value.project_type
return formatProjectType(data.$getProjectTypeForDisplay(projectType, project.value.loaders))
})
const following = computed(() => {
@@ -2372,7 +2365,7 @@ useHead({
],
})
if (!route.name.startsWith('type-id-settings')) {
if (!route.name.startsWith('type-project-settings')) {
useSeoMeta({
title: () => title.value,
description: () => description.value,
@@ -2460,7 +2453,6 @@ async function patchProject(resData, quiet = false) {
{ projectId: project.value.id, data: resData },
{
onSuccess: async () => {
await updateProjectRoute()
if (!quiet) {
addNotification({
title: formatMessage(messages.projectUpdated),
@@ -360,7 +360,6 @@ const formatDateTimeShort = useFormatDateTime({
minute: 'numeric',
})
const route = useRoute()
const vintl = useVIntl()
const { formatMessage } = vintl
@@ -373,13 +372,15 @@ const messages = defineMessages({
},
})
const chargeId = useRouteId('charge')
const {
data: user,
error: userError,
suspense: userSuspense,
} = useQuery({
queryKey: ['user', route.params.id],
queryFn: () => labrinth.users_v2.get(route.params.id),
queryKey: ['user', chargeId],
queryFn: () => labrinth.users_v2.get(chargeId),
})
onServerPrefetch(userSuspense)
@@ -529,8 +529,8 @@ const returnLink = computed(() => {
return null
})
const collectionId = computed(() => route.params.id)
const isFollowingCollection = computed(() => collectionId.value === 'following')
const collectionId = useRouteId('collection')
const isFollowingCollection = computed(() => collectionId === 'following')
// Static collection for "following" page
const followingCollection = computed(() =>
@@ -555,15 +555,15 @@ const {
error: collectionError,
isPending: collectionIsPending,
} = useQuery({
queryKey: computed(() => ['collection', collectionId.value]),
queryFn: () => api.labrinth.collections.get(collectionId.value),
enabled: computed(() => !!collectionId.value && !isFollowingCollection.value),
queryKey: computed(() => ['collection', collectionId]),
queryFn: () => api.labrinth.collections.get(collectionId),
enabled: computed(() => !!collectionId && !isFollowingCollection.value),
})
watch(
collectionError,
(error) => {
if (error && collectionId.value && !isFollowingCollection.value) {
if (error && collectionId && !isFollowingCollection.value) {
const status = error.statusCode ?? error.status ?? 404
showError({
fatal: true,
@@ -1,7 +1,7 @@
<template>
<ReportView
:auth="auth"
:report-id="route.params.id"
:report-id="reportId"
:breadcrumbs-stack="[
{ href: '/dashboard/reports', label: formatMessage(messages.activeReportsTitle) },
]"
@@ -25,10 +25,10 @@ const messages = defineMessages({
},
})
const route = useNativeRoute()
const auth = await useAuth()
const reportId = useRouteId('report')
useHead({
title: () => `${formatMessage(messages.reportTitle, { id: route.params.id })} - Modrinth`,
title: () => `${formatMessage(messages.reportTitle, { id: reportId })} - Modrinth`,
})
</script>
+1 -1
View File
@@ -868,7 +868,7 @@ const messages = defineMessages({
faqLocationAnswer: {
id: 'hosting-marketing.faq.location.answer',
defaultMessage:
"We have servers available in North America, Europe, and Southeast Asia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support.",
"We have servers available across North America, Europe, and Southeast Asia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support.",
},
faqIncreaseStorage: {
id: 'hosting-marketing.faq.increase-storage',
@@ -9,8 +9,6 @@
:site-url="config.public.siteUrl as string"
:products="products"
:auth-user="authUser"
:fetch-intercom-token="fetchIntercomToken"
:intercom-app-id="config.public.intercomAppId as string"
:navigate-to-billing="() => router.push('/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
:browse-modpacks="
@@ -77,12 +75,6 @@ const authUser = auth.value?.user
}
: undefined
async function fetchIntercomToken(): Promise<{ token: string }> {
return $fetch('/api/intercom/messenger-jwt', {
query: { server_id: serverId },
})
}
async function resolveViewer(): Promise<{ userId: string | null; userRole: string | null }> {
return {
userId: auth.value?.user?.id ?? null,
+7 -2
View File
@@ -102,7 +102,12 @@
<tr>
<td>Revenue earned on</td>
<td>
<StyledInput id="revenue-date-picker" v-model="rawSelectedDate" type="date" />
<DatePicker
id="revenue-date-picker"
v-model="rawSelectedDate"
show-today
position="above"
/>
<noscript
>(JavaScript must be enabled for the date picker to function, example date:
2024-07-15)
@@ -162,7 +167,7 @@
</template>
<script lang="ts" setup>
import { injectModrinthClient, StyledInput, useFormatDateTime, useFormatMoney } from '@modrinth/ui'
import { DatePicker, injectModrinthClient, useFormatDateTime, useFormatMoney } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
+83 -26
View File
@@ -34,7 +34,7 @@
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
@select="goToPage(1)"
@@ -42,7 +42,7 @@
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<SortAscIcon
v-if="currentSortType === 'Oldest'"
v-if="currentSortType === 'Oldest' || currentSortType === 'Least external deps'"
class="size-5 flex-shrink-0 text-secondary"
/>
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
@@ -113,6 +113,7 @@
v-else
:key="item.project.id"
:queue-entry="item"
:show-external-dependencies="currentFilterType === MODPACK_FILTER_TYPE"
@start-from-project="startFromProject"
/>
</div>
@@ -246,12 +247,25 @@ const filterTypes: ComboboxOption<string>[] = [
const filterTypeValues = filterTypes.map((option) => option.value)
const DEFAULT_FILTER_TYPE = filterTypeValues[0]
const sortTypes: ComboboxOption<string>[] = [
const MODPACK_FILTER_TYPE = 'Modpacks'
const baseSortTypes: ComboboxOption<string>[] = [
{ value: 'Oldest', label: 'Oldest' },
{ value: 'Newest', label: 'Newest' },
]
const sortTypeValues = sortTypes.map((option) => option.value)
const DEFAULT_SORT_TYPE = sortTypeValues[0]
const modpackSortTypes: ComboboxOption<string>[] = [
{ value: 'Most external deps', label: 'Most external deps' },
{ value: 'Least external deps', label: 'Least external deps' },
]
const DEFAULT_SORT_TYPE = baseSortTypes[0].value
const modpackSortTypeValues = modpackSortTypes.map((option) => option.value)
const sortTypes = computed(() => {
if (currentFilterType.value === MODPACK_FILTER_TYPE) {
return [...baseSortTypes, ...modpackSortTypes]
}
return baseSortTypes
})
const itemsPerPageOptions: ComboboxOption<number>[] = [
{ value: 20, label: '20' },
@@ -269,17 +283,31 @@ function parseFilterTypeFromQuery(value: LocationQueryValue | LocationQueryValue
return filterTypeValues.includes(query) ? query : DEFAULT_FILTER_TYPE
}
function parseSortTypeFromQuery(value: LocationQueryValue | LocationQueryValue[]): string {
function parseSortTypeFromQuery(
value: LocationQueryValue | LocationQueryValue[],
filterType: string,
): string {
const query = queryAsStringOrEmpty(value)
return sortTypeValues.includes(query) ? query : DEFAULT_SORT_TYPE
const validValues = [
...baseSortTypes.map((option) => option.value),
...(filterType === MODPACK_FILTER_TYPE ? modpackSortTypeValues : []),
]
return validValues.includes(query) ? query : DEFAULT_SORT_TYPE
}
const currentFilterType = ref(parseFilterTypeFromQuery(route.query.filter))
const currentSortType = ref(parseSortTypeFromQuery(route.query.sort))
const currentSortType = ref(parseSortTypeFromQuery(route.query.sort, currentFilterType.value))
watch(
currentFilterType,
(newFilter) => {
if (
newFilter !== MODPACK_FILTER_TYPE &&
modpackSortTypeValues.includes(currentSortType.value)
) {
currentSortType.value = DEFAULT_SORT_TYPE
}
const currentQuery = { ...route.query }
if (newFilter && newFilter !== DEFAULT_FILTER_TYPE) {
currentQuery.filter = newFilter
@@ -326,7 +354,7 @@ watch(
watch(
() => route.query.sort,
(newSortParam) => {
const newValue = parseSortTypeFromQuery(newSortParam)
const newValue = parseSortTypeFromQuery(newSortParam, currentFilterType.value)
if (currentSortType.value !== newValue) {
currentSortType.value = newValue
}
@@ -423,7 +451,23 @@ const typeFiltered = computed(() => {
const filteredProjects = computed(() => {
const filtered = [...typeFiltered.value]
if (currentSortType.value === 'Oldest') {
if (currentSortType.value === 'Most external deps') {
filtered.sort((a, b) => {
const depsDiff = b.external_dependencies_count - a.external_dependencies_count
if (depsDiff !== 0) return depsDiff
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateA - dateB
})
} else if (currentSortType.value === 'Least external deps') {
filtered.sort((a, b) => {
const depsDiff = a.external_dependencies_count - b.external_dependencies_count
if (depsDiff !== 0) return depsDiff
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateA - dateB
})
} else if (currentSortType.value === 'Oldest') {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
@@ -471,7 +515,7 @@ function goToPage(page: number) {
currentPage.value = page
}
async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
async function findFirstEligibleProject(): Promise<ModerationProject | null> {
let skippedCount = 0
while (moderationQueue.hasItems) {
@@ -481,24 +525,30 @@ async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
const project = filteredProjects.value.find((p) => p.project.id === currentId)
if (!project) {
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
continue
}
if (project.project.status !== 'processing') {
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
continue
}
try {
const lockStatus = await moderationQueue.checkLock(currentId)
if (!lockStatus.locked || lockStatus.expired) {
if (!lockStatus.locked || lockStatus.expired || lockStatus.is_own_lock) {
if (skippedCount > 0) {
addNotification({
title: 'Skipped locked projects',
text: `Skipped ${skippedCount} project(s) being moderated by others.`,
title: 'Skipped projects',
text: `Skipped ${skippedCount} project(s) already moderated or locked by others.`,
type: 'info',
})
}
return project
}
// Project is locked, skip it
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
} catch {
@@ -506,6 +556,14 @@ async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
}
}
if (skippedCount > 0) {
addNotification({
title: 'Skipped projects',
text: `Skipped ${skippedCount} project(s) already moderated or locked by others.`,
type: 'info',
})
}
return null
}
@@ -517,22 +575,22 @@ async function moderateAllInFilter() {
await moderationQueue.setQueue(projectIds)
// Find first unlocked project
const targetProject = await findFirstUnlockedProject()
const targetProject = await findFirstEligibleProject()
if (!targetProject) {
addNotification({
title: 'All projects locked',
text: 'All projects in queue are currently being moderated by others.',
title: 'No projects available',
text: 'All projects in queue are already moderated or locked by others.',
type: 'warning',
})
return
}
navigateTo({
name: 'type-id',
name: 'type-project',
params: {
type: 'project',
id: targetProject.project.slug,
project: targetProject.project.slug,
},
state: {
showChecklist: true,
@@ -553,23 +611,22 @@ async function startFromProject(projectId: string) {
await moderationQueue.setQueue(projectIds)
}
// Find first unlocked project
const targetProject = await findFirstUnlockedProject()
const targetProject = await findFirstEligibleProject()
if (!targetProject) {
addNotification({
title: 'All projects locked',
text: 'All projects in queue are currently being moderated by others.',
title: 'No projects available',
text: 'All projects in queue are already moderated or locked by others.',
type: 'warning',
})
return
}
navigateTo({
name: 'type-id',
name: 'type-project',
params: {
type: 'project',
id: targetProject.project.slug,
project: targetProject.project.slug,
},
state: {
showChecklist: true,
@@ -7,8 +7,7 @@ import ModerationReportCard from '~/components/ui/moderation/ModerationReportCar
import { enrichReportBatch } from '~/helpers/moderation.ts'
const client = injectModrinthClient()
const { params } = useRoute()
const reportId = params.id as string
const reportId = String(useRouteId('report'))
const { data: report } = useQuery({
queryKey: computed(() => ['report', reportId]),
@@ -9,13 +9,12 @@ import MaliciousSummaryModal, {
} from '~/components/ui/moderation/MaliciousSummaryModal.vue'
import ModerationTechRevCard from '~/components/ui/moderation/ModerationTechRevCard.vue'
const route = useRoute()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const projectId = computed(() => route.params.id as string)
const projectId = String(useRouteId('project'))
useHead({ title: () => `Tech review - ${projectId.value} - Modrinth` })
useHead({ title: () => `Tech review - ${projectId} - Modrinth` })
const CACHE_TTL = 24 * 60 * 60 * 1000
const CACHE_KEY_PREFIX = 'tech_review_source_'
@@ -151,7 +150,7 @@ const {
} = useQuery({
queryKey: ['tech-review-project-report', projectId],
queryFn: async () => {
return await client.labrinth.tech_review_internal.getProjectReport(projectId.value)
return await client.labrinth.tech_review_internal.getProjectReport(projectId)
},
retry: false,
})
@@ -163,7 +162,7 @@ const {
} = useQuery({
queryKey: ['project', projectId],
queryFn: async () => {
return await client.labrinth.projects_v3.get(projectId.value)
return await client.labrinth.projects_v3.get(projectId)
},
retry: false,
})
@@ -171,14 +170,14 @@ const {
const { data: organizationData, isLoading: isLoadingOrg } = useQuery({
queryKey: ['project-organization', projectId],
queryFn: async () => {
return await client.labrinth.projects_v3.getOrganization(projectId.value)
return await client.labrinth.projects_v3.getOrganization(projectId)
},
})
const { data: membersData, isLoading: isLoadingMembers } = useQuery({
queryKey: ['project-members', projectId],
queryFn: async () => {
return await client.labrinth.projects_v3.getMembers(projectId.value)
return await client.labrinth.projects_v3.getMembers(projectId)
},
enabled: computed(() => !organizationData.value && !isLoadingOrg.value),
})
@@ -265,7 +264,7 @@ function handleShowMaliciousSummary(unsafeFiles: UnsafeFile[]) {
}
function refetch() {
queryClient.invalidateQueries({ queryKey: ['tech-review-project-report', projectId.value] })
queryClient.invalidateQueries({ queryKey: ['tech-review-project-report', projectId] })
}
</script>
@@ -348,7 +348,7 @@ const tags = useGeneratedState()
const config = useRuntimeConfig()
const modal_creation = useTemplateRef('modal_creation')
const orgId = useRouteId()
const orgId = useRouteId('organization')
if (route.path.includes('settings')) {
useSeoMeta({
@@ -85,7 +85,7 @@ const showPreviewImage = (files) => {
}
}
const orgId = useRouteId()
const orgId = useRouteId('orgId')
const save = async () => {
// Save field changes via useSavable
+13 -13
View File
@@ -16,10 +16,10 @@
"
:width="'550px'"
>
<div class="flex flex-col gap-4">
<div class="flex w-full flex-col">
<div class="flex flex-col gap-6">
<div class="flex w-full flex-col gap-2.5">
<label for="pat-name">
<span class="label__title">{{ formatMessage(createModalMessages.nameLabel) }}</span>
<span class="font-semibold">{{ formatMessage(createModalMessages.nameLabel) }}</span>
</label>
<StyledInput
id="pat-name"
@@ -29,20 +29,20 @@
/>
</div>
<div class="flex w-full flex-col">
<div class="flex w-full flex-col gap-2.5">
<label for="pat-scopes">
<span class="label__title">{{ formatMessage(commonMessages.scopesLabel) }}</span>
<span class="font-semibold">{{ formatMessage(commonMessages.scopesLabel) }}</span>
</label>
<div
id="pat-scopes"
class="scope-items mt-2 grid grid-cols-1 gap-x-6 gap-y-4 min-[600px]:grid-cols-2"
class="scope-items grid grid-cols-1 gap-x-6 gap-y-4 min-[600px]:grid-cols-2"
>
<div
v-for="category in scopeCategories"
:key="category.name"
class="flex flex-col gap-2"
class="flex flex-col gap-1.5"
>
<h4 class="m-0 border-b border-divider pb-1 text-base font-bold text-contrast">
<h4 class="m-0 border-b border-divider text-base font-medium text-primary">
{{ category.name }}
</h4>
<div class="flex flex-col gap-2">
@@ -58,15 +58,14 @@
</div>
</div>
<div class="flex w-full flex-col">
<div class="flex w-full flex-col gap-2.5">
<label for="pat-expires">
<span class="label__title">{{ formatMessage(createModalMessages.expiresLabel) }}</span>
<span class="font-semibold">{{ formatMessage(createModalMessages.expiresLabel) }}</span>
</label>
<StyledInput id="pat-expires" v-model="expires" type="date" />
<p></p>
<DatePicker id="pat-expires" v-model="expires" show-today wrapper-class="w-full" />
</div>
<div class="ml-auto flex gap-2">
<div class="ml-auto mt-4 flex gap-2">
<ButtonStyled type="outlined">
<button @click="$refs.patModal.hide()">
<XIcon />
@@ -207,6 +206,7 @@ import {
commonSettingsMessages,
ConfirmModal,
CopyCode,
DatePicker,
defineMessages,
injectModrinthClient,
injectNotificationManager,
@@ -685,13 +685,15 @@ const messages = defineMessages({
const client = injectModrinthClient()
const userId = useRouteId('user')
const {
data: user,
error: userError,
suspense: userSuspense,
} = useQuery({
queryKey: computed(() => ['user', route.params.id]),
queryFn: () => client.labrinth.users_v2.get(route.params.id),
queryKey: computed(() => ['user', userId]),
queryFn: () => client.labrinth.users_v2.get(userId),
})
watch(
@@ -710,9 +712,9 @@ watch(
)
const { data: projects, suspense: projectsSuspense } = useQuery({
queryKey: computed(() => ['user', route.params.id, 'projects']),
queryKey: computed(() => ['user', userId, 'projects']),
queryFn: async () => {
const projects = await client.labrinth.users_v2.getProjects(route.params.id)
const projects = await client.labrinth.users_v2.getProjects(userId)
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
project.project_type = data.$getProjectTypeForUrl(
@@ -726,13 +728,13 @@ const { data: projects, suspense: projectsSuspense } = useQuery({
})
const { data: organizations, suspense: orgsSuspense } = useQuery({
queryKey: computed(() => ['user', route.params.id, 'organizations']),
queryFn: () => client.labrinth.users_v2.getOrganizations(route.params.id),
queryKey: computed(() => ['user', userId, 'organizations']),
queryFn: () => client.labrinth.users_v2.getOrganizations(userId),
})
const { data: collections, suspense: collectionsSuspense } = useQuery({
queryKey: computed(() => ['user', route.params.id, 'collections']),
queryFn: () => client.labrinth.users_v2.getCollections(route.params.id),
queryKey: computed(() => ['user', userId, 'collections']),
queryFn: () => client.labrinth.users_v2.getCollections(userId),
})
onServerPrefetch(async () => {
@@ -865,7 +867,7 @@ const isAdminViewing = computed(() => isAdmin(auth.value.user))
async function toggleAffiliate(id) {
await client.labrinth.users_v2.patch(id, { badges: user.value.badges ^ (1 << 7) })
queryClient.invalidateQueries({ queryKey: ['user', route.params.id] })
queryClient.invalidateQueries({ queryKey: ['user', userId] })
}
const navLinks = computed(() => [
@@ -0,0 +1,31 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id,\n external_dependencies_count as \"external_dependencies_count!\"\n FROM (\n SELECT DISTINCT ON (m.id)\n m.id,\n m.queued,\n (\n SELECT COUNT(*)\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE v.mod_id = m.id\n AND d.dependency_file_name IS NOT NULL\n ) external_dependencies_count\n FROM mods m\n\n /* -- Temporarily, don't exclude projects in tech rev q\n\n -- exclude projects in tech review queue\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.project_id = m.id AND didws.status = 'pending'\n */\n\n WHERE\n m.status = $1\n /* AND didws.status IS NULL */ -- Temporarily don't exclude\n\n GROUP BY m.id\n ) t\n WHERE\n ($4::boolean IS NULL OR (external_dependencies_count > 0) = $4)\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "external_dependencies_count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8",
"Bool"
]
},
"nullable": [
false,
null
]
},
"hash": "119a59fcf4bb2f19f89002c712a67c75d30056143c0bcabdbd74bb4c7b442082"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tUPDATE analytics_events\n\t\t\tSET meta = $2, starts = $3, ends = $4\n\t\t\tWHERE id = $1\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Jsonb",
"Timestamptz",
"Timestamptz"
]
},
"nullable": []
},
"hash": "2e6ffe58fef1368fdd1377534b27ec367b211113464c6c79a3c9e226ae3fdb79"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tINSERT INTO analytics_events (id, meta, starts, ends)\n\t\t\tVALUES ($1, $2, $3, $4)\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Jsonb",
"Timestamptz",
"Timestamptz"
]
},
"nullable": []
},
"hash": "67dbb27773b9d5dc77ee7ea9ce8b0509d3a6b4cc6c4b194901d1690203020977"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM analytics_events WHERE id=$1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "94cec16e1be48761fe78a87fcead205bb294479eaae95fd1e9cb00b0975ca297"
}
@@ -0,0 +1,36 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n t.mod_id AS \"project_id!\",\n tm.created,\n tm.body AS \"body: sqlx::types::Json<MessageBody>\"\n FROM threads_messages tm\n INNER JOIN threads t ON t.id = tm.thread_id\n WHERE\n t.mod_id = ANY($1)\n AND tm.body->>'type' = 'status_change'\n AND tm.created BETWEEN $2 AND $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "body: sqlx::types::Json<MessageBody>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Int8Array",
"Timestamptz",
"Timestamptz"
]
},
"nullable": [
true,
false,
false
]
},
"hash": "a5141c0435441f062231c842cb5db5e0c78f8f3896c1e9f2b2b56cce41fa1591"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tDELETE FROM analytics_events\n\t\t\tWHERE id = $1\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "a53a7681f3e8ab918d225d16690b20d5856c1747c651a52e7d94d8a2ee23dc08"
}
@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT id, meta AS \"meta: Json<AnalyticsEventMeta>\", starts, ends\n\t\t\tFROM analytics_events\n\t\t\tORDER BY starts DESC\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "meta: Json<AnalyticsEventMeta>",
"type_info": "Jsonb"
},
{
"ordinal": 2,
"name": "starts",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "ends",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "c7817c07dc91b562dfabc3b8c4bade55b8c920df7d98adde36c9e5d1cf5801f1"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, mod_id\n FROM versions\n WHERE mod_id = ANY($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "mod_id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false,
false
]
},
"hash": "e425e8fce0571388d90cbe861223e47d858deca66530ffdba4d0eac3aeac1433"
}
@@ -1,24 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id\n FROM (\n SELECT DISTINCT ON (m.id)\n m.id,\n m.queued\n FROM mods m\n\n /* -- Temporarily, don't exclude projects in tech rev q\n\n -- exclude projects in tech review queue\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.project_id = m.id AND didws.status = 'pending'\n */\n\n WHERE\n m.status = $1\n /* AND didws.status IS NULL */ -- Temporarily don't exclude\n\n GROUP BY m.id\n ) t\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "ec1f08768071d55613b0b69b3eac43e1e5d0a532171b5de7b9086ae7376f1482"
}
+7 -1
View File
@@ -1,5 +1,11 @@
- Use `ApiError` as the error type for API routes
- Prefer `ApiError::Internal` and `ApiError::Request` over `ApiError::InvalidInput`
- The return type of an HTTP route should not be `HttpResponse` if possible; always prefer more specific types
- Use `web::Json<T>` for JSON-encoded response
- Use `()` for no content
- Prefer `ApiError` variants:
- `ApiError::Request` instead of `ApiError::InvalidInput`
- `ApiError::Auth` instead of `ApiError::CustomAuthentication`
- `ApiError::Internal` for database errors, 3rd party service errors, anything else internal
- Use `eyre!` to construct a value for `Internal` and `Request` variants
- Error messages (both for errors and exceptions) must be formatted as per the Rust API guidelines:
- lowercase message
@@ -0,0 +1,19 @@
-- Dummy ClickHouse download rows for exercising v3 analytics download-source normalization.
-- Run this against the analytics ClickHouse database, for example:
-- curl -u default:default 'http://localhost:8123/?database=staging_ariadne' --data-binary @apps/labrinth/fixtures/analytics-changes-clickhouse.sql
--
-- Project 910000000000003 = 4AP3jpvKl
INSERT INTO downloads FORMAT JSONEachRow
{"recorded":"2026-05-13 00:05:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"modrinth/kyros/1.0.0 (support@modrinth.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:10:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"modrinth/theseus/0.8.6 (support@modrinth.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:15:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Gradle/8.8","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:20:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"MultiMC/5.0","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:25:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"PrismLauncher/6.1","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:30:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"PolyMC/7.0","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:35:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) FeatherLauncher/2.6.12-c Chrome/144.0.7559.236 Electron/40.9.1 Safari/537.36 (hello@feathermc.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:40:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"FeatherMC/Feather Client Rust Launcher/1.0.0 (hello@feathermc.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:45:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.134 Feather/9b6bb39d Safari/537.36","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:50:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"PandoraLauncher/1.2.3","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:55:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Mozilla/5.0 AppleWebKit/605.1.15","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:59:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"curl/8.7.1","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
@@ -0,0 +1,97 @@
-- Dummy analytics data for exercising v3 analytics events and project download analytics.
-- IDs are listed as integers, followed by their equivalent base62 representation.
-- User 103587649610509 = 1XZwx9qL
INSERT INTO users (
id, username, email, role, badges, balance, email_verified
)
VALUES (
103587649610509, 'Analytics Admin', 'analytics-admin@modrinth.com',
'admin', 0, 0, TRUE
)
ON CONFLICT (id) DO UPDATE SET
role = EXCLUDED.role;
INSERT INTO sessions (
id, session, user_id, expires, refresh_expires, ip, user_agent
)
VALUES (
103587649610510, 'mra_analytics_admin', 103587649610509,
'2030-01-01T00:00:00Z', '2030-01-01T00:00:00Z',
'127.0.0.1', 'analytics fixture'
)
ON CONFLICT (session) DO UPDATE SET
user_id = EXCLUDED.user_id,
expires = EXCLUDED.expires,
refresh_expires = EXCLUDED.refresh_expires;
-- Project 910000000000003 = 4AP3jpvKl
-- Team 910000000000001 = 4AP3jpvKj
-- Thread 910000000000004 = 4AP3jpvKm
INSERT INTO teams (id)
VALUES (910000000000001)
ON CONFLICT (id) DO NOTHING;
INSERT INTO team_members (
id, team_id, user_id, role, permissions, accepted, payouts_split, ordering,
organization_permissions, is_owner
)
VALUES (
910000000000002, 910000000000001, 103587649610509, 'Owner',
1023, TRUE, 100, 0, NULL, TRUE
)
ON CONFLICT (id) DO UPDATE SET
permissions = EXCLUDED.permissions,
accepted = EXCLUDED.accepted,
is_owner = EXCLUDED.is_owner;
INSERT INTO mods (
id, team_id, name, summary, downloads, slug, description, follows,
license, status, requested_status, monetization_status,
side_types_migration_review_status, components
)
VALUES (
910000000000003, 910000000000001, 'Analytics Fixture Project',
'Project used by analytics fixture data.', 0, 'analytics-fixture-project',
'', 0, 'LicenseRef-All-Rights-Reserved', 'approved', 'approved',
'monetized', 'reviewed', '{}'::jsonb
)
ON CONFLICT (id) DO UPDATE SET
team_id = EXCLUDED.team_id,
status = EXCLUDED.status,
requested_status = EXCLUDED.requested_status,
monetization_status = EXCLUDED.monetization_status;
INSERT INTO threads (id, thread_type, mod_id)
VALUES (910000000000004, 'project', 910000000000003)
ON CONFLICT (id) DO NOTHING;
-- Analytics events used to test /v3/analytics-event and Redis caching.
-- Event 910000000000101 = 4AP3jpvMR
-- Event 910000000000102 = 4AP3jpvMS
INSERT INTO analytics_events (id, meta, starts, ends)
VALUES
(
910000000000101,
'{
"title": "Downloads launch",
"announcement_url": "https://modrinth.com/news/downloads-launch",
"for_metric_kind": ["downloads"]
}'::jsonb,
'2026-05-13T00:00:00Z',
'2026-05-14T00:00:00Z'
),
(
910000000000102,
'{
"title": "Revenue promo",
"announcement_url": "https://modrinth.com/news/revenue-promo",
"for_metric_kind": ["revenue"]
}'::jsonb,
'2026-05-14T00:00:00Z',
'2026-05-15T00:00:00Z'
)
ON CONFLICT (id) DO UPDATE SET
meta = EXCLUDED.meta,
starts = EXCLUDED.starts,
ends = EXCLUDED.ends;
@@ -0,0 +1,6 @@
create table analytics_events (
id bigint primary key,
meta jsonb not null,
starts timestamptz not null,
ends timestamptz not null
);
@@ -0,0 +1,140 @@
use chrono::{DateTime, Utc};
use futures::{StreamExt, TryStreamExt};
use sqlx::types::Json;
use crate::{
database::{
models::{DBAnalyticsEventId, DatabaseError},
redis::RedisPool,
},
models::v3::analytics_event::AnalyticsEventMeta,
};
use serde::{Deserialize, Serialize};
const ANALYTICS_EVENTS_NAMESPACE: &str = "analytics_events";
const ANALYTICS_EVENTS_ALL_KEY: &str = "all";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DBAnalyticsEvent {
pub id: DBAnalyticsEventId,
pub meta: AnalyticsEventMeta,
pub starts: DateTime<Utc>,
pub ends: DateTime<Utc>,
}
impl DBAnalyticsEvent {
pub async fn insert(
&self,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<(), DatabaseError> {
sqlx::query!(
"
INSERT INTO analytics_events (id, meta, starts, ends)
VALUES ($1, $2, $3, $4)
",
self.id as DBAnalyticsEventId,
sqlx::types::Json(&self.meta) as Json<&AnalyticsEventMeta>,
self.starts,
self.ends,
)
.execute(exec)
.await?;
Ok(())
}
pub async fn update(
&self,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<bool, DatabaseError> {
let result = sqlx::query!(
"
UPDATE analytics_events
SET meta = $2, starts = $3, ends = $4
WHERE id = $1
",
self.id as DBAnalyticsEventId,
sqlx::types::Json(&self.meta) as Json<&AnalyticsEventMeta>,
self.starts,
self.ends,
)
.execute(exec)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn remove(
id: DBAnalyticsEventId,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<bool, DatabaseError> {
let result = sqlx::query!(
"
DELETE FROM analytics_events
WHERE id = $1
",
id as DBAnalyticsEventId,
)
.execute(exec)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn get_all(
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
redis: &RedisPool,
) -> Result<Vec<DBAnalyticsEvent>, DatabaseError> {
let mut redis = redis.connect().await?;
if let Some(events) = redis
.get_deserialized_from_json(
ANALYTICS_EVENTS_NAMESPACE,
ANALYTICS_EVENTS_ALL_KEY,
)
.await?
{
return Ok(events);
}
let events = sqlx::query!(
r#"
SELECT id, meta AS "meta: Json<AnalyticsEventMeta>", starts, ends
FROM analytics_events
ORDER BY starts DESC
"#
)
.fetch(exec)
.map(|record| {
let record = record?;
Ok::<_, DatabaseError>(DBAnalyticsEvent {
id: DBAnalyticsEventId(record.id),
meta: record.meta.0,
starts: record.starts,
ends: record.ends,
})
})
.try_collect::<Vec<_>>()
.await?;
redis
.set_serialized_to_json(
ANALYTICS_EVENTS_NAMESPACE,
ANALYTICS_EVENTS_ALL_KEY,
&events,
None,
)
.await?;
Ok(events)
}
pub async fn clear_cache(redis: &RedisPool) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
redis
.delete(ANALYTICS_EVENTS_NAMESPACE, ANALYTICS_EVENTS_ALL_KEY)
.await?;
Ok(())
}
}
+10 -6
View File
@@ -1,12 +1,12 @@
use super::DatabaseError;
use crate::database::PgTransaction;
use crate::models::ids::{
AffiliateCodeId, ChargeId, CollectionId, FileId, ImageId, NotificationId,
OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId,
OAuthRedirectUriId, OrganizationId, PatId, PayoutId, ProductId,
ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId,
SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId, ThreadMessageId,
UserSubscriptionId, VersionId,
AffiliateCodeId, AnalyticsEventId, ChargeId, CollectionId, FileId, ImageId,
NotificationId, OAuthAccessTokenId, OAuthClientAuthorizationId,
OAuthClientId, OAuthRedirectUriId, OrganizationId, PatId, PayoutId,
ProductId, ProductPriceId, ProjectId, ReportId, SessionId,
SharedInstanceId, SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId,
ThreadMessageId, UserSubscriptionId, VersionId,
};
use ariadne::ids::base62_impl::to_base62;
use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range};
@@ -269,6 +269,10 @@ db_id_interface!(
AffiliateCodeId,
generator: generate_affiliate_code_id @ "affiliate_codes",
);
db_id_interface!(
AnalyticsEventId,
generator: generate_analytics_event_id @ "analytics_events",
);
id_type!(CategoryId as i32);
id_type!(GameId as i32);
+2
View File
@@ -1,6 +1,7 @@
use thiserror::Error;
pub mod affiliate_code_item;
pub mod analytics_event_item;
pub mod categories;
pub mod charge_item;
pub mod collection_item;
@@ -44,6 +45,7 @@ pub mod users_subscriptions_credits;
pub mod version_item;
pub use affiliate_code_item::DBAffiliateCode;
pub use analytics_event_item::DBAnalyticsEvent;
pub use collection_item::DBCollection;
pub use ids::*;
pub use image_item::DBImage;
@@ -0,0 +1,55 @@
use std::collections::HashSet;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::models::ids::AnalyticsEventId;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AnalyticsEvent {
pub id: AnalyticsEventId,
#[serde(flatten)]
pub meta: AnalyticsEventMeta,
pub starts: DateTime<Utc>,
pub ends: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AnalyticsEventMeta {
#[serde(default)]
pub title: String,
#[serde(default)]
pub announcement_url: Option<String>,
#[serde(default)]
pub for_metric_kind: HashSet<MetricKind>,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum MetricKind {
Views,
Downloads,
Playtime,
Revenue,
}
impl From<crate::database::models::DBAnalyticsEvent> for AnalyticsEvent {
fn from(data: crate::database::models::DBAnalyticsEvent) -> Self {
Self {
id: data.id.into(),
meta: data.meta,
starts: data.starts,
ends: data.ends,
}
}
}
+1
View File
@@ -26,3 +26,4 @@ base62_id!(ThreadMessageId);
base62_id!(UserSubscriptionId);
base62_id!(VersionId);
base62_id!(AffiliateCodeId);
base62_id!(AnalyticsEventId);

Some files were not shown because too many files have changed in this diff Show More