mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb8f5eacbd | ||
|
|
71dee4de40 | ||
|
|
f74fad0cae | ||
|
|
07e81ac036 | ||
|
|
6e7835fb35 | ||
|
|
2f95c4c441 | ||
|
|
451b2d0e44 | ||
|
|
215643c846 | ||
|
|
d8b1415f9c | ||
|
|
3eeb549d20 | ||
|
|
c3fe7b4232 | ||
|
|
079a10bba9 | ||
|
|
3c3d5702ba | ||
|
|
a34576a2c3 | ||
|
|
d8e4915a31 | ||
|
|
ed723fa186 | ||
|
|
d6c8d4475b | ||
|
|
302b60d89c | ||
|
|
f106dc580f | ||
|
|
244c263e40 |
Generated
+3
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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(() => [
|
||||
{
|
||||
|
||||
@@ -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,78 @@ 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 getContentOperationKeys(item: ContentItem) {
|
||||
return [item.id, item.file_path, item.file_name, item.project?.id, item.version?.id].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 +338,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 +364,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 +416,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,32 +425,55 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +481,8 @@ async function handleUpdate(id: string) {
|
||||
const item = projects.value.find((p) => p.id === id)
|
||||
if (!item?.has_update || !item.project?.id || !item.version?.id) return
|
||||
|
||||
const requestId = beginUpdateRequest()
|
||||
|
||||
debug('handleUpdate triggered', {
|
||||
fileName: item.file_name,
|
||||
projectType: item.project_type,
|
||||
@@ -384,12 +503,47 @@ 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) || updatingProject.value?.id !== item.id) return
|
||||
|
||||
loadingVersions.value = false
|
||||
|
||||
if (!versions) {
|
||||
@@ -414,6 +568,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 +594,8 @@ async function handleUpdate(id: string) {
|
||||
async function handleSwitchVersion(item: ContentItem) {
|
||||
if (!item.project?.id || !item.version?.id) return
|
||||
|
||||
const requestId = beginUpdateRequest()
|
||||
|
||||
updatingModpack.value = false
|
||||
updatingProject.value = item
|
||||
updatingProjectVersions.value = []
|
||||
@@ -435,6 +610,8 @@ async function handleSwitchVersion(item: ContentItem) {
|
||||
return handleError(e)
|
||||
})) as Labrinth.Versions.v2.Version[] | null
|
||||
|
||||
if (!isActiveUpdateRequest(requestId) || updatingProject.value?.id !== item.id) return
|
||||
|
||||
loadingVersions.value = false
|
||||
|
||||
if (!versions) return
|
||||
@@ -468,9 +645,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 +675,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 +718,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 +745,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 +762,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 +789,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 +815,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 +839,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 +927,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 +962,7 @@ async function initProjects(cacheBehaviour?: CacheBehaviour) {
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
return true
|
||||
}
|
||||
|
||||
provideAppBackup({
|
||||
@@ -844,10 +1090,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 +1118,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 +1153,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 +1186,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)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"types": ["vite/client"],
|
||||
|
||||
"paths": {
|
||||
"@modrinth/api-client": ["../../packages/api-client/src/index.ts"],
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -739,8 +739,8 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
|
||||
} else {
|
||||
// Fallback: use project ID (will trigger middleware redirect)
|
||||
navigateTo({
|
||||
name: 'type-id',
|
||||
params: { type: 'project', id: next.projectId },
|
||||
name: 'type-project',
|
||||
params: { type: 'project', project: next.projectId },
|
||||
state: { showChecklist: true },
|
||||
})
|
||||
}
|
||||
@@ -1067,8 +1067,8 @@ async function skipToNextProject() {
|
||||
} else {
|
||||
// Fallback: use project ID
|
||||
navigateTo({
|
||||
name: 'type-id',
|
||||
params: { type: 'project', id },
|
||||
name: 'type-project',
|
||||
params: { type: 'project', project: id },
|
||||
state: { showChecklist: true },
|
||||
})
|
||||
}
|
||||
@@ -2127,8 +2127,8 @@ async function endChecklist(status?: string) {
|
||||
} else {
|
||||
// Fallback: use project ID
|
||||
navigateTo({
|
||||
name: 'type-id',
|
||||
params: { type: 'project', id },
|
||||
name: 'type-project',
|
||||
params: { type: 'project', project: id },
|
||||
state: { showChecklist: true },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
-37
@@ -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),
|
||||
+4
-3
@@ -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)
|
||||
+6
-6
@@ -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,
|
||||
+3
-3
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -529,10 +529,10 @@ async function moderateAllInFilter() {
|
||||
}
|
||||
|
||||
navigateTo({
|
||||
name: 'type-id',
|
||||
name: 'type-project',
|
||||
params: {
|
||||
type: 'project',
|
||||
id: targetProject.project.slug,
|
||||
project: targetProject.project.slug,
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
@@ -566,10 +566,10 @@ async function startFromProject(projectId: string) {
|
||||
}
|
||||
|
||||
navigateTo({
|
||||
name: 'type-id',
|
||||
name: 'type-project',
|
||||
params: {
|
||||
type: 'project',
|
||||
id: targetProject.project.slug,
|
||||
project: targetProject.project.slug,
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
|
||||
+1
-2
@@ -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]),
|
||||
+7
-8
@@ -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>
|
||||
|
||||
+1
-1
@@ -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({
|
||||
+1
-1
@@ -85,7 +85,7 @@ const showPreviewImage = (files) => {
|
||||
}
|
||||
}
|
||||
|
||||
const orgId = useRouteId()
|
||||
const orgId = useRouteId('orgId')
|
||||
|
||||
const save = async () => {
|
||||
// Save field changes via useSavable
|
||||
@@ -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(() => [
|
||||
Generated
+31
@@ -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"
|
||||
}
|
||||
Generated
+17
@@ -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"
|
||||
}
|
||||
Generated
+17
@@ -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"
|
||||
}
|
||||
Generated
+22
@@ -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"
|
||||
}
|
||||
Generated
+14
@@ -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"
|
||||
}
|
||||
Generated
+38
@@ -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"
|
||||
}
|
||||
Generated
+28
@@ -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"
|
||||
}
|
||||
Generated
-24
@@ -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"
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,3 +26,4 @@ base62_id!(ThreadMessageId);
|
||||
base62_id!(UserSubscriptionId);
|
||||
base62_id!(VersionId);
|
||||
base62_id!(AffiliateCodeId);
|
||||
base62_id!(AnalyticsEventId);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod affiliate_code;
|
||||
pub mod analytics;
|
||||
pub mod analytics_event;
|
||||
pub mod billing;
|
||||
pub mod collections;
|
||||
pub mod ids;
|
||||
|
||||
@@ -1117,7 +1117,14 @@ pub async fn init(
|
||||
}
|
||||
|
||||
let user_id = if let Some(token) = info.token {
|
||||
let (_, user) = get_user_record_from_bearer_token(
|
||||
// Linking a new auth provider changes how the account can be accessed,
|
||||
// so only first-party session tokens may authorize this flow. OAuth and
|
||||
// PAT tokens can be delegated or stored outside an interactive login.
|
||||
if !token.starts_with("mra_") {
|
||||
return Err(AuthenticationError::InvalidCredentials);
|
||||
}
|
||||
|
||||
let (scopes, user) = get_user_record_from_bearer_token(
|
||||
&req,
|
||||
Some(&token),
|
||||
&**client,
|
||||
@@ -1128,6 +1135,10 @@ pub async fn init(
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
if !scopes.contains(Scopes::USER_AUTH_WRITE) {
|
||||
return Err(AuthenticationError::InvalidCredentials);
|
||||
}
|
||||
|
||||
Some(user.id)
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -53,6 +53,9 @@ pub struct ProjectsRequestOptions {
|
||||
/// How many projects to skip.
|
||||
#[serde(default)]
|
||||
pub offset: u32,
|
||||
/// Whether to filter by modpacks that have external dependencies.
|
||||
#[serde(default)]
|
||||
pub has_external_dependencies: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_count() -> u16 {
|
||||
@@ -68,6 +71,8 @@ pub struct FetchedProject {
|
||||
pub project: Project,
|
||||
/// Who owns the project.
|
||||
pub ownership: Ownership,
|
||||
/// How many external file dependencies the project has.
|
||||
pub external_dependencies_count: i64,
|
||||
}
|
||||
|
||||
/// Fetched information on who owns a project.
|
||||
@@ -190,13 +195,22 @@ pub async fn get_projects_internal(
|
||||
|
||||
use futures::stream::TryStreamExt;
|
||||
|
||||
let project_ids = sqlx::query!(
|
||||
"
|
||||
SELECT id
|
||||
let project_rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
external_dependencies_count as "external_dependencies_count!"
|
||||
FROM (
|
||||
SELECT DISTINCT ON (m.id)
|
||||
m.id,
|
||||
m.queued
|
||||
m.queued,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM versions v
|
||||
INNER JOIN dependencies d ON d.dependent_id = v.id
|
||||
WHERE v.mod_id = m.id
|
||||
AND d.dependency_file_name IS NOT NULL
|
||||
) external_dependencies_count
|
||||
FROM mods m
|
||||
|
||||
/* -- Temporarily, don't exclude projects in tech rev q
|
||||
@@ -212,20 +226,36 @@ pub async fn get_projects_internal(
|
||||
|
||||
GROUP BY m.id
|
||||
) t
|
||||
WHERE
|
||||
($4::boolean IS NULL OR (external_dependencies_count > 0) = $4)
|
||||
ORDER BY queued ASC
|
||||
OFFSET $3
|
||||
LIMIT $2
|
||||
",
|
||||
"#,
|
||||
ProjectStatus::Processing.as_str(),
|
||||
request_opts.count as i64,
|
||||
request_opts.offset as i64
|
||||
request_opts.offset as i64,
|
||||
request_opts.has_external_dependencies,
|
||||
)
|
||||
.fetch(&**pool)
|
||||
.map_ok(|m| database::models::DBProjectId(m.id))
|
||||
.try_collect::<Vec<database::models::DBProjectId>>()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch projects awaiting review")?;
|
||||
|
||||
let project_ids = project_rows
|
||||
.iter()
|
||||
.map(|m| database::models::DBProjectId(m.id))
|
||||
.collect::<Vec<_>>();
|
||||
let project_metadata = project_rows
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
(
|
||||
database::models::DBProjectId(m.id),
|
||||
m.external_dependencies_count,
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let projects =
|
||||
database::DBProject::get_many_ids(&project_ids, &**pool, &redis)
|
||||
.await
|
||||
@@ -240,7 +270,16 @@ pub async fn get_projects_internal(
|
||||
|
||||
let map_project =
|
||||
|(project, ownership): (Project, Ownership)| -> FetchedProject {
|
||||
FetchedProject { ownership, project }
|
||||
let external_dependencies_count = project_metadata
|
||||
.get(&database::models::DBProjectId(project.id.0 as i64))
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
|
||||
FetchedProject {
|
||||
ownership,
|
||||
project,
|
||||
external_dependencies_count,
|
||||
}
|
||||
};
|
||||
|
||||
let projects = projects
|
||||
|
||||
@@ -61,6 +61,7 @@ pub async fn get_projects(
|
||||
web::Query(internal::moderation::ProjectsRequestOptions {
|
||||
count: count.count,
|
||||
offset: 0,
|
||||
has_external_dependencies: None,
|
||||
}),
|
||||
session_queue,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
use actix_web::{HttpRequest, delete, get, patch, post, web};
|
||||
use chrono::{DateTime, Utc};
|
||||
use eyre::eyre;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::get_user_from_headers,
|
||||
database::{
|
||||
PgPool,
|
||||
models::{
|
||||
DBAnalyticsEvent, DBAnalyticsEventId, generate_analytics_event_id,
|
||||
},
|
||||
redis::RedisPool,
|
||||
},
|
||||
models::{
|
||||
ids::AnalyticsEventId,
|
||||
pats::Scopes,
|
||||
v3::analytics_event::{AnalyticsEvent, AnalyticsEventMeta},
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(analytics_events_get)
|
||||
.service(analytics_event_create)
|
||||
.service(analytics_event_edit)
|
||||
.service(analytics_event_delete);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AnalyticsEventUpsert {
|
||||
#[serde(flatten)]
|
||||
pub meta: AnalyticsEventMeta,
|
||||
pub starts: DateTime<Utc>,
|
||||
pub ends: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Fetches all analytics events.
|
||||
#[utoipa::path(responses((status = OK, body = Vec<AnalyticsEvent>)))]
|
||||
#[get("")]
|
||||
pub async fn analytics_events_get(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
) -> Result<web::Json<Vec<AnalyticsEvent>>, ApiError> {
|
||||
let events = DBAnalyticsEvent::get_all(&**pool, &redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch analytics events")?
|
||||
.into_iter()
|
||||
.map(AnalyticsEvent::from)
|
||||
.collect();
|
||||
|
||||
Ok(web::Json(events))
|
||||
}
|
||||
|
||||
/// Creates an analytics event.
|
||||
#[utoipa::path(responses((status = OK, body = AnalyticsEvent)))]
|
||||
#[post("")]
|
||||
pub async fn analytics_event_create(
|
||||
req: HttpRequest,
|
||||
event: web::Json<AnalyticsEventUpsert>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut transaction = pool
|
||||
.begin()
|
||||
.await
|
||||
.wrap_internal_err("failed to begin transaction")?;
|
||||
let id = generate_analytics_event_id(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to generate analytics event ID")?;
|
||||
|
||||
let event = DBAnalyticsEvent {
|
||||
id,
|
||||
meta: event.meta.clone(),
|
||||
starts: event.starts,
|
||||
ends: event.ends,
|
||||
};
|
||||
event
|
||||
.insert(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to insert analytics event")?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.wrap_internal_err("failed to commit transaction")?;
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(web::Json(event.into()))
|
||||
}
|
||||
|
||||
/// Edits an analytics event.
|
||||
#[utoipa::path(responses((status = OK, body = AnalyticsEvent)))]
|
||||
#[patch("/{id}")]
|
||||
pub async fn analytics_event_edit(
|
||||
req: HttpRequest,
|
||||
id: web::Path<(AnalyticsEventId,)>,
|
||||
event: web::Json<AnalyticsEventUpsert>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let event = DBAnalyticsEvent {
|
||||
id: DBAnalyticsEventId::from(id.into_inner().0),
|
||||
meta: event.meta.clone(),
|
||||
starts: event.starts,
|
||||
ends: event.ends,
|
||||
};
|
||||
|
||||
let updated = event
|
||||
.update(&**pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to update analytics event")?;
|
||||
if !updated {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(web::Json(event.into()))
|
||||
}
|
||||
|
||||
/// Deletes an analytics event.
|
||||
#[utoipa::path(responses((status = NO_CONTENT)))]
|
||||
#[delete("/{id}")]
|
||||
pub async fn analytics_event_delete(
|
||||
req: HttpRequest,
|
||||
id: web::Path<(AnalyticsEventId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<(), ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let deleted = DBAnalyticsEvent::remove(
|
||||
DBAnalyticsEventId::from(id.into_inner().0),
|
||||
&**pool,
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("failed to delete analytics event")?;
|
||||
if !deleted {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -9,15 +9,16 @@
|
||||
|
||||
mod old;
|
||||
|
||||
use std::num::NonZeroU64;
|
||||
use std::{num::NonZeroU64, sync::LazyLock};
|
||||
|
||||
use crate::database::PgPool;
|
||||
use actix_web::{HttpRequest, post, web};
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use eyre::eyre;
|
||||
use futures::StreamExt;
|
||||
use regex::Regex;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||
|
||||
use crate::{
|
||||
auth::{AuthenticationError, get_user_from_headers},
|
||||
@@ -167,8 +168,8 @@ pub enum ProjectDownloadsField {
|
||||
VersionId,
|
||||
/// Referrer domain which linked to this project.
|
||||
Domain,
|
||||
/// Modrinth site path which was visited, e.g. `/mod/foo`.
|
||||
SitePath,
|
||||
/// Normalized user agent used to download this project.
|
||||
UserAgent,
|
||||
/// Whether these downloads were monetized or not.
|
||||
Monetized,
|
||||
/// What country these downloads came from.
|
||||
@@ -325,9 +326,9 @@ pub struct ProjectDownloads {
|
||||
/// [`ProjectDownloadsField::Domain`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
domain: Option<String>,
|
||||
/// [`ProjectDownloadsField::SitePath`].
|
||||
/// [`ProjectDownloadsField::UserAgent`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
site_path: Option<String>,
|
||||
user_agent: Option<DownloadSource>,
|
||||
/// [`ProjectDownloadsField::VersionId`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
version_id: Option<VersionId>,
|
||||
@@ -350,6 +351,56 @@ pub struct ProjectDownloads {
|
||||
downloads: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, utoipa::ToSchema)]
|
||||
pub enum DownloadSource {
|
||||
Website,
|
||||
ModrinthApp,
|
||||
ModrinthHosting,
|
||||
ModrinthMaven,
|
||||
Other,
|
||||
Named(String),
|
||||
}
|
||||
|
||||
impl Serialize for DownloadSource {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match self {
|
||||
Self::Named(name) => serializer.serialize_str(name),
|
||||
Self::Website => serializer.serialize_str("website"),
|
||||
Self::ModrinthApp => serializer.serialize_str("modrinth_app"),
|
||||
Self::ModrinthHosting => {
|
||||
serializer.serialize_str("modrinth_hosting")
|
||||
}
|
||||
Self::ModrinthMaven => serializer.serialize_str("modrinth_maven"),
|
||||
Self::Other => serializer.serialize_str("other"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DownloadSource {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let source = String::deserialize(deserializer)?;
|
||||
Ok(match source.as_str() {
|
||||
"website" => Self::Website,
|
||||
"modrinth_app" => Self::ModrinthApp,
|
||||
"modrinth_hosting" => Self::ModrinthHosting,
|
||||
"modrinth_maven" => Self::ModrinthMaven,
|
||||
"other" => Self::Other,
|
||||
_ if !source.is_empty() => Self::Named(source),
|
||||
_ => {
|
||||
return Err(D::Error::custom(
|
||||
"download source cannot be empty",
|
||||
));
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ReturnMetrics::project_playtime`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProjectPlaytime {
|
||||
@@ -476,7 +527,7 @@ mod query {
|
||||
pub bucket: u64,
|
||||
pub project_id: DBProjectId,
|
||||
pub domain: String,
|
||||
pub site_path: String,
|
||||
pub user_agent: String,
|
||||
pub version_id: DBVersionId,
|
||||
pub monetized: i8,
|
||||
pub country: String,
|
||||
@@ -489,7 +540,7 @@ mod query {
|
||||
pub const DOWNLOADS: &str = {
|
||||
const USE_PROJECT_ID: &str = "{use_project_id: Bool}";
|
||||
const USE_DOMAIN: &str = "{use_domain: Bool}";
|
||||
const USE_SITE_PATH: &str = "{use_site_path: Bool}";
|
||||
const USE_USER_AGENT: &str = "{use_user_agent: Bool}";
|
||||
const USE_VERSION_ID: &str = "{use_version_id: Bool}";
|
||||
const USE_MONETIZED: &str = "{use_monetized: Bool}";
|
||||
const USE_COUNTRY: &str = "{use_country: Bool}";
|
||||
@@ -502,7 +553,7 @@ mod query {
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
if({USE_PROJECT_ID}, project_id, 0) AS project_id,
|
||||
if({USE_DOMAIN}, domain, '') AS domain,
|
||||
if({USE_SITE_PATH}, site_path, '') AS site_path,
|
||||
if({USE_USER_AGENT}, user_agent, '') AS user_agent,
|
||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||
if({USE_MONETIZED}, CAST(user_id != 0 AS Int8), -1) AS monetized,
|
||||
if({USE_COUNTRY}, country, '') AS country,
|
||||
@@ -517,7 +568,7 @@ mod query {
|
||||
-- not the possibly-zero one,
|
||||
-- by using `downloads.project_id` instead of `project_id`
|
||||
AND downloads.project_id IN {PROJECT_IDS}
|
||||
GROUP BY bucket, project_id, domain, site_path, version_id, monetized, country, reason, game_version, loader"
|
||||
GROUP BY bucket, project_id, domain, user_agent, version_id, monetized, country, reason, game_version, loader"
|
||||
)
|
||||
};
|
||||
|
||||
@@ -538,23 +589,48 @@ mod query {
|
||||
const USE_LOADER: &str = "{use_loader: Bool}";
|
||||
const USE_GAME_VERSION: &str = "{use_game_version: Bool}";
|
||||
const USE_COUNTRY: &str = "{use_country: Bool}";
|
||||
const PARENT_VERSION_IDS: &str = "{parent_version_ids: Array(UInt64)}";
|
||||
const PARENT_VERSION_PROJECT_IDS: &str =
|
||||
"{parent_version_project_ids: Array(UInt64)}";
|
||||
|
||||
formatcp!(
|
||||
"SELECT
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
if({USE_PROJECT_ID}, project_id, 0) AS project_id,
|
||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||
if({USE_LOADER}, loader, '') AS loader,
|
||||
if({USE_GAME_VERSION}, game_version, '') AS game_version,
|
||||
if({USE_COUNTRY}, country, '') AS country,
|
||||
bucket,
|
||||
if({USE_PROJECT_ID}, source_project_id, 0) AS project_id,
|
||||
version_id,
|
||||
loader,
|
||||
game_version,
|
||||
country,
|
||||
SUM(seconds) AS seconds
|
||||
FROM playtime
|
||||
WHERE
|
||||
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
|
||||
-- make sure that the REAL project id is included,
|
||||
-- not the possibly-zero one,
|
||||
-- by using `playtime.project_id` instead of `project_id`
|
||||
AND playtime.project_id IN {PROJECT_IDS}
|
||||
FROM (
|
||||
SELECT
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
project_id AS source_project_id,
|
||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||
if({USE_LOADER}, loader, '') AS loader,
|
||||
if({USE_GAME_VERSION}, game_version, '') AS game_version,
|
||||
if({USE_COUNTRY}, country, '') AS country,
|
||||
seconds
|
||||
FROM playtime
|
||||
WHERE
|
||||
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
|
||||
AND playtime.project_id IN {PROJECT_IDS}
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
|
||||
transform(parent, {PARENT_VERSION_IDS}, {PARENT_VERSION_PROJECT_IDS}) AS source_project_id,
|
||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||
if({USE_LOADER}, loader, '') AS loader,
|
||||
if({USE_GAME_VERSION}, game_version, '') AS game_version,
|
||||
if({USE_COUNTRY}, country, '') AS country,
|
||||
seconds
|
||||
FROM playtime
|
||||
WHERE
|
||||
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
|
||||
AND parent IN {PARENT_VERSION_IDS}
|
||||
)
|
||||
GROUP BY bucket, project_id, version_id, loader, game_version, country"
|
||||
)
|
||||
};
|
||||
@@ -672,6 +748,27 @@ pub async fn fetch_analytics(
|
||||
let project_ids =
|
||||
filter_allowed_project_ids(&project_ids, &user, &pool, &redis).await?;
|
||||
|
||||
let project_id_values =
|
||||
project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
|
||||
let parent_versions = sqlx::query!(
|
||||
"
|
||||
SELECT id, mod_id
|
||||
FROM versions
|
||||
WHERE mod_id = ANY($1)
|
||||
",
|
||||
&project_id_values,
|
||||
)
|
||||
.fetch_all(&**pool)
|
||||
.await?;
|
||||
let parent_version_ids = parent_versions
|
||||
.iter()
|
||||
.map(|version| DBVersionId(version.id))
|
||||
.collect::<Vec<_>>();
|
||||
let parent_version_project_ids = parent_versions
|
||||
.iter()
|
||||
.map(|version| DBProjectId(version.mod_id))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let affiliate_code_ids =
|
||||
DBAffiliateCode::get_by_affiliate(user.id.into(), &**pool)
|
||||
.await?
|
||||
@@ -684,6 +781,8 @@ pub async fn fetch_analytics(
|
||||
req: &req,
|
||||
time_slices: &mut time_slices,
|
||||
project_ids: &project_ids,
|
||||
parent_version_ids: &parent_version_ids,
|
||||
parent_version_project_ids: &parent_version_project_ids,
|
||||
affiliate_code_ids: &affiliate_code_ids,
|
||||
};
|
||||
|
||||
@@ -737,7 +836,7 @@ pub async fn fetch_analytics(
|
||||
&[
|
||||
("use_project_id", uses(F::ProjectId)),
|
||||
("use_domain", uses(F::Domain)),
|
||||
("use_site_path", uses(F::SitePath)),
|
||||
("use_user_agent", uses(F::UserAgent)),
|
||||
("use_version_id", uses(F::VersionId)),
|
||||
("use_monetized", uses(F::Monetized)),
|
||||
("use_country", uses(F::Country)),
|
||||
@@ -756,7 +855,11 @@ pub async fn fetch_analytics(
|
||||
source_project: row.project_id.into(),
|
||||
metrics: ProjectMetrics::Downloads(ProjectDownloads {
|
||||
domain: none_if_empty(row.domain),
|
||||
site_path: none_if_empty(row.site_path),
|
||||
user_agent: if uses(F::UserAgent) {
|
||||
normalize_download_source(&row.user_agent)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
version_id: none_if_zero_version_id(row.version_id),
|
||||
monetized: match row.monetized {
|
||||
0 => Some(false),
|
||||
@@ -840,9 +943,6 @@ pub async fn fetch_analytics(
|
||||
return Err(AuthenticationError::InvalidCredentials.into());
|
||||
}
|
||||
|
||||
let project_id_values =
|
||||
project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
|
||||
|
||||
let mut rows = sqlx::query!(
|
||||
"SELECT
|
||||
WIDTH_BUCKET(
|
||||
@@ -1016,6 +1116,76 @@ fn none_if_zero_version_id(v: DBVersionId) -> Option<VersionId> {
|
||||
if v.0 == 0 { None } else { Some(v.into()) }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum DownloadSourcePattern {
|
||||
Named(&'static str),
|
||||
Website,
|
||||
ModrinthApp,
|
||||
ModrinthHosting,
|
||||
ModrinthMaven,
|
||||
}
|
||||
|
||||
impl DownloadSourcePattern {
|
||||
fn into_source(self) -> DownloadSource {
|
||||
match self {
|
||||
Self::Named(name) => DownloadSource::Named(name.into()),
|
||||
Self::Website => DownloadSource::Website,
|
||||
Self::ModrinthApp => DownloadSource::ModrinthApp,
|
||||
Self::ModrinthHosting => DownloadSource::ModrinthHosting,
|
||||
Self::ModrinthMaven => DownloadSource::ModrinthMaven,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static DOWNLOAD_SOURCE_PATTERNS: LazyLock<Vec<(Regex, DownloadSourcePattern)>> =
|
||||
LazyLock::new(|| {
|
||||
use DownloadSourcePattern as P;
|
||||
|
||||
[
|
||||
(r"^modrinth/kyros/", P::ModrinthHosting),
|
||||
(r"^modrinth/theseus/", P::ModrinthApp),
|
||||
(r"^(Gradle/|Apache-Maven/)", P::ModrinthMaven),
|
||||
(r"^MultiMC/", P::Named("MultiMC")),
|
||||
(r"^PrismLauncher/", P::Named("Prism Launcher")),
|
||||
(r"^PolyMC/", P::Named("PolyMC")),
|
||||
(r"^FCL/", P::Named("FCL")),
|
||||
(r"^PCL2/", P::Named("PCL2")),
|
||||
(r"^HMCL/", P::Named("HMCL")),
|
||||
(r"^Lunar Client Launcher", P::Named("Lunar Client")),
|
||||
(r"^PojavLauncher", P::Named("PojavLauncher")),
|
||||
(r"^ATLauncher/", P::Named("ATLauncher")),
|
||||
(r"FeatherLauncher/", P::Named("Feather Client")),
|
||||
(
|
||||
r"^FeatherMC/Feather Client Rust Launcher/",
|
||||
P::Named("Feather Client"),
|
||||
),
|
||||
(r"Feather/[0-9A-Za-z]+", P::Named("Feather Client")),
|
||||
(r"^PandoraLauncher/", P::Named("Pandora Launcher")),
|
||||
(r"^unsup", P::Named("unsup")),
|
||||
(r"nothub/mrpack-install", P::Named("mrpack-install")),
|
||||
(r"^(packwiz-installer|packwiz/)", P::Named("Packwiz")),
|
||||
(
|
||||
r"^(Mozilla/|Chrome/|Chromium/|Firefox/|Safari/|AppleWebKit/|Edg/|OPR/)",
|
||||
P::Website,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(pattern, source)| {
|
||||
(
|
||||
Regex::new(pattern)
|
||||
.expect("download source regex should be valid"),
|
||||
source,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
fn normalize_download_source(user_agent: &str) -> Option<DownloadSource> {
|
||||
DOWNLOAD_SOURCE_PATTERNS.iter().find_map(|(regex, source)| {
|
||||
regex.is_match(user_agent).then(|| source.into_source())
|
||||
})
|
||||
}
|
||||
|
||||
fn condense_country(country: String, count: u64) -> String {
|
||||
// Every country under '50' (view or downloads) should be condensed into 'XX'
|
||||
if count < 50 {
|
||||
@@ -1030,6 +1200,8 @@ struct QueryClickhouseContext<'a> {
|
||||
req: &'a GetRequest,
|
||||
time_slices: &'a mut [TimeSlice],
|
||||
project_ids: &'a [DBProjectId],
|
||||
parent_version_ids: &'a [DBVersionId],
|
||||
parent_version_project_ids: &'a [DBProjectId],
|
||||
affiliate_code_ids: &'a [DBAffiliateCodeId],
|
||||
}
|
||||
|
||||
@@ -1051,6 +1223,8 @@ where
|
||||
.param("time_range_end", cx.req.time_range.end.timestamp())
|
||||
.param("time_slices", cx.time_slices.len())
|
||||
.param("project_ids", cx.project_ids)
|
||||
.param("parent_version_ids", cx.parent_version_ids)
|
||||
.param("parent_version_project_ids", cx.parent_version_project_ids)
|
||||
.param("affiliate_code_ids", cx.affiliate_code_ids);
|
||||
for (param_name, used) in use_columns {
|
||||
query = query.param(param_name, used)
|
||||
@@ -1170,6 +1344,51 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_download_sources() {
|
||||
let cases = [
|
||||
("MultiMC/5.0", Some(DownloadSource::Named("MultiMC".into()))),
|
||||
(
|
||||
"PrismLauncher/6.1",
|
||||
Some(DownloadSource::Named("Prism Launcher".into())),
|
||||
),
|
||||
(
|
||||
"modrinth/theseus/0.8.6 (support@modrinth.com)",
|
||||
Some(DownloadSource::ModrinthApp),
|
||||
),
|
||||
(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
|
||||
Some(DownloadSource::Website),
|
||||
),
|
||||
("curl/8.7.1", None),
|
||||
];
|
||||
|
||||
for (user_agent, source) in cases {
|
||||
assert_eq!(normalize_download_source(user_agent), source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_source_serializes_as_raw_string() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(DownloadSource::Named("MultiMC".into()))
|
||||
.unwrap(),
|
||||
json!("MultiMC")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(DownloadSource::Website).unwrap(),
|
||||
json!("website")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(DownloadSource::ModrinthApp).unwrap(),
|
||||
json!("modrinth_app")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(DownloadSource::Other).unwrap(),
|
||||
json!("other")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_format() {
|
||||
let test_project_1 = ProjectId(123);
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::util::cors::default_cors;
|
||||
use actix_web::{HttpResponse, web};
|
||||
use serde_json::json;
|
||||
|
||||
pub mod analytics_event;
|
||||
pub mod analytics_get;
|
||||
pub mod collections;
|
||||
pub mod friends;
|
||||
@@ -59,6 +60,11 @@ pub fn utoipa_config(
|
||||
.wrap(default_cors())
|
||||
.configure(analytics_get::config),
|
||||
);
|
||||
cfg.service(
|
||||
utoipa_actix_web::scope("/v3/analytics-event")
|
||||
.wrap(default_cors())
|
||||
.configure(analytics_event::config),
|
||||
);
|
||||
cfg.service(
|
||||
utoipa_actix_web::scope("/v3/payout")
|
||||
.wrap(default_cors())
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ISO3166Module } from './iso3166'
|
||||
import { KyrosContentV1Module } from './kyros/content/v1'
|
||||
import { KyrosFilesV0Module } from './kyros/files/v0'
|
||||
import { KyrosLogsV1Module } from './kyros/logs/v1'
|
||||
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
|
||||
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
|
||||
import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal'
|
||||
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
|
||||
@@ -71,6 +72,7 @@ export const MODULE_REGISTRY = {
|
||||
kyros_content_v1: KyrosContentV1Module,
|
||||
kyros_files_v0: KyrosFilesV0Module,
|
||||
kyros_logs_v1: KyrosLogsV1Module,
|
||||
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
|
||||
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
|
||||
labrinth_auth_internal: LabrinthAuthInternalModule,
|
||||
labrinth_auth_v2: LabrinthAuthV2Module,
|
||||
|
||||
@@ -14,6 +14,7 @@ export class KyrosContentV1Module extends AbstractModule {
|
||||
* @param files - Files to upload as addons
|
||||
* @param options - Optional progress callback
|
||||
* @returns UploadHandle with promise, onProgress, and cancel
|
||||
* @deprecated Use `kyros.upload_sessions_v1` so cancellation can remove staged addon files before finalize.
|
||||
*/
|
||||
public uploadAddonFile(
|
||||
worldId: string,
|
||||
|
||||
@@ -94,6 +94,7 @@ export class KyrosFilesV0Module extends AbstractModule {
|
||||
* @param file - File to upload
|
||||
* @param options - Optional progress callback and feature overrides
|
||||
* @returns UploadHandle with promise, onProgress, and cancel
|
||||
* @deprecated Use `kyros.upload_sessions_v1` for bulk uploads so cancellation can remove staged files before finalize.
|
||||
*/
|
||||
public uploadFile(
|
||||
path: string,
|
||||
|
||||
@@ -1,4 +1,32 @@
|
||||
export namespace Kyros {
|
||||
export namespace UploadSessions {
|
||||
export namespace v1 {
|
||||
export type Scope = 'content' | 'files'
|
||||
export type UploadSessionStatus =
|
||||
| 'active'
|
||||
| 'uploading'
|
||||
| 'finalizing'
|
||||
| 'cancelled'
|
||||
| 'finalized'
|
||||
| 'expired'
|
||||
|
||||
export interface UploadSessionResponse {
|
||||
upload_id: string
|
||||
status: UploadSessionStatus
|
||||
created_at: number
|
||||
updated_at: number
|
||||
last_upload_at: number | null
|
||||
expires_at: number
|
||||
entry_count: number
|
||||
uploaded_byte_count: number
|
||||
}
|
||||
|
||||
export interface GetUploadSessionResponse {
|
||||
session: UploadSessionResponse | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Files {
|
||||
export namespace v0 {
|
||||
export interface DirectoryItem {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { UploadHandle, UploadProgress } from '../../../types/upload'
|
||||
import type { Kyros } from '../types'
|
||||
|
||||
export type UploadSessionFile = {
|
||||
file: File | Blob
|
||||
filename: string
|
||||
}
|
||||
|
||||
export class KyrosUploadSessionsV1Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'kyros_upload_sessions_v1'
|
||||
}
|
||||
|
||||
public async create(
|
||||
scope: Kyros.UploadSessions.v1.Scope,
|
||||
worldId: string,
|
||||
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
|
||||
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
|
||||
`/worlds/${worldId}/files/upload-session`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
useNodeAuth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public async get(
|
||||
scope: Kyros.UploadSessions.v1.Scope,
|
||||
worldId: string,
|
||||
): Promise<Kyros.UploadSessions.v1.GetUploadSessionResponse> {
|
||||
return this.client.request<Kyros.UploadSessions.v1.GetUploadSessionResponse>(
|
||||
`/worlds/${worldId}/files/upload-session`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'GET',
|
||||
useNodeAuth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public uploadFiles(
|
||||
scope: Kyros.UploadSessions.v1.Scope,
|
||||
worldId: string,
|
||||
uploadId: string,
|
||||
files: UploadSessionFile[],
|
||||
options?: {
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
retry?: boolean | number
|
||||
},
|
||||
): UploadHandle<Kyros.UploadSessions.v1.UploadSessionResponse> {
|
||||
const formData = new FormData()
|
||||
for (const { file, filename } of files) {
|
||||
formData.append('file', file, filename)
|
||||
}
|
||||
|
||||
return this.client.upload<Kyros.UploadSessions.v1.UploadSessionResponse>(
|
||||
`/worlds/${worldId}/files/upload-session/${uploadId}/files`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
formData,
|
||||
onProgress: options?.onProgress,
|
||||
retry: options?.retry,
|
||||
useNodeAuth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public async finalize(
|
||||
scope: Kyros.UploadSessions.v1.Scope,
|
||||
worldId: string,
|
||||
uploadId: string,
|
||||
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
|
||||
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
|
||||
`/worlds/${worldId}/files/upload-session/${uploadId}/finalize`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
useNodeAuth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public async cancel(
|
||||
scope: Kyros.UploadSessions.v1.Scope,
|
||||
worldId: string,
|
||||
uploadId: string,
|
||||
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
|
||||
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
|
||||
`/worlds/${worldId}/files/upload-session/${uploadId}`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'DELETE',
|
||||
useNodeAuth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -875,6 +875,7 @@ impl CachedEntry {
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
for row in query {
|
||||
let parsed_data = if let Some(data) = row.data.clone() {
|
||||
Some(Self::deserialize_cache_value(type_, data, &row.id)?)
|
||||
@@ -882,7 +883,7 @@ impl CachedEntry {
|
||||
None
|
||||
};
|
||||
|
||||
if row.expires <= Utc::now().timestamp() {
|
||||
if row.expires <= now {
|
||||
if cache_behaviour == CacheBehaviour::MustRevalidate {
|
||||
continue;
|
||||
} else {
|
||||
@@ -890,6 +891,19 @@ impl CachedEntry {
|
||||
}
|
||||
}
|
||||
|
||||
let row_id = row.id.clone();
|
||||
let row_alias = row.alias.clone();
|
||||
let remove_matching_key = |x: &&str| {
|
||||
x != &&*row_id
|
||||
&& !row_alias.as_ref().is_some_and(|y| {
|
||||
if type_.case_sensitive_alias().unwrap_or(true) {
|
||||
x == y
|
||||
} else {
|
||||
y.to_lowercase() == x.to_lowercase()
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if let Some(data) = parsed_data {
|
||||
if data.get_type() != type_ {
|
||||
return Err(crate::ErrorKind::OtherError(format!(
|
||||
@@ -901,17 +915,7 @@ impl CachedEntry {
|
||||
.as_error());
|
||||
}
|
||||
|
||||
remaining_keys.retain(|x| {
|
||||
x != &&*row.id
|
||||
&& !row.alias.as_ref().is_some_and(|y| {
|
||||
if type_.case_sensitive_alias().unwrap_or(true)
|
||||
{
|
||||
x == y
|
||||
} else {
|
||||
y.to_lowercase() == x.to_lowercase()
|
||||
}
|
||||
})
|
||||
});
|
||||
remaining_keys.retain(remove_matching_key);
|
||||
|
||||
return_vals.push(Self {
|
||||
id: row.id,
|
||||
@@ -920,6 +924,8 @@ impl CachedEntry {
|
||||
data: Some(data),
|
||||
expires: row.expires,
|
||||
});
|
||||
} else {
|
||||
remaining_keys.retain(remove_matching_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
//! ## Data Flow
|
||||
//!
|
||||
//! 1. Frontend calls `get_content_items(profile_path)`
|
||||
//! 2. Backend fetches all installed files via `Profile::get_projects()`
|
||||
//! 3. If profile is linked to a modpack:
|
||||
//! 2. If profile is linked to a modpack:
|
||||
//! - Fetch modpack file hashes from cache (populated during installation)
|
||||
//! - Fallback: re-download .mrpack if cache miss (cleared/expired)
|
||||
//! - Filter out files that belong to the modpack
|
||||
//! 4. For remaining files, fetch project/version/owner metadata in parallel
|
||||
//! 5. Return sorted `ContentItem` list
|
||||
//! - Filter out files that belong to the modpack before update lookup
|
||||
//! 3. For remaining files, fetch project/version/owner metadata in parallel
|
||||
//! 4. Return sorted `ContentItem` list
|
||||
//!
|
||||
//! ## Caching
|
||||
//!
|
||||
@@ -226,12 +225,8 @@ pub async fn get_linked_modpack_info(
|
||||
};
|
||||
|
||||
// Check for updates
|
||||
let (has_update, update_version_id, update_version) = check_modpack_update(
|
||||
profile,
|
||||
&linked_data.version_id,
|
||||
&version,
|
||||
all_versions,
|
||||
);
|
||||
let (has_update, update_version_id, update_version) =
|
||||
check_modpack_update(&linked_data.version_id, &version, all_versions);
|
||||
|
||||
Ok(Some(LinkedModpackInfo {
|
||||
project,
|
||||
@@ -243,10 +238,9 @@ pub async fn get_linked_modpack_info(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check if a newer compatible version exists for the linked modpack.
|
||||
/// Check if a newer version exists for the linked modpack.
|
||||
/// Returns (has_update, update_version_id, update_version).
|
||||
fn check_modpack_update(
|
||||
profile: &Profile,
|
||||
installed_version_id: &str,
|
||||
installed_version: &Version,
|
||||
all_versions: Option<Vec<Version>>,
|
||||
@@ -255,44 +249,19 @@ fn check_modpack_update(
|
||||
return (false, None, None);
|
||||
};
|
||||
|
||||
// Get the loader as a string for comparison
|
||||
let loader_str = profile.loader.as_str().to_lowercase();
|
||||
let game_version = &profile.game_version;
|
||||
|
||||
// Filter to compatible versions
|
||||
let mut compatible_versions: Vec<&Version> = versions
|
||||
let mut newer_versions: Vec<&Version> = versions
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
// Must support the profile's game version
|
||||
let supports_game = v.game_versions.contains(game_version);
|
||||
|
||||
// Must support the profile's loader
|
||||
// The v2 API replaces "mrpack" with actual loaders from mrpack_loaders,
|
||||
// but if mrpack_loaders is missing, loaders may be just ["mrpack"].
|
||||
// In that case we can't filter by loader, so accept the version.
|
||||
let real_loaders: Vec<_> = v
|
||||
.loaders
|
||||
.iter()
|
||||
.filter(|l| l.to_lowercase() != "mrpack")
|
||||
.collect();
|
||||
let supports_loader = real_loaders.is_empty()
|
||||
|| real_loaders.iter().any(|l| l.to_lowercase() == loader_str);
|
||||
|
||||
supports_game && supports_loader
|
||||
v.id != installed_version_id
|
||||
&& v.date_published > installed_version.date_published
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by date_published descending (newest first)
|
||||
compatible_versions.sort_by_key(|b| std::cmp::Reverse(b.date_published));
|
||||
newer_versions.sort_by_key(|b| std::cmp::Reverse(b.date_published));
|
||||
|
||||
// Find the newest compatible version
|
||||
if let Some(newest) = compatible_versions.first() {
|
||||
// Check if the newest version is different and newer than installed
|
||||
if newest.id != installed_version_id
|
||||
&& newest.date_published > installed_version.date_published
|
||||
{
|
||||
return (true, Some(newest.id.clone()), Some((*newest).clone()));
|
||||
}
|
||||
if let Some(newest) = newer_versions.first() {
|
||||
return (true, Some(newest.id.clone()), Some((*newest).clone()));
|
||||
}
|
||||
|
||||
(false, None, None)
|
||||
@@ -306,10 +275,6 @@ pub async fn get_content_items(
|
||||
pool: &SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<Vec<ContentItem>> {
|
||||
let all_files = profile
|
||||
.get_projects(cache_behaviour, pool, fetch_semaphore)
|
||||
.await?;
|
||||
|
||||
let modpack_ids = if let Some(ref linked_data) = profile.linked_data {
|
||||
if linked_data.version_id.is_empty() {
|
||||
None
|
||||
@@ -350,23 +315,35 @@ pub async fn get_content_items(
|
||||
None
|
||||
};
|
||||
|
||||
let user_files: Vec<(String, ProfileFile)> = all_files
|
||||
.into_iter()
|
||||
.filter(|(_, file)| {
|
||||
modpack_ids
|
||||
.as_ref()
|
||||
.is_none_or(|ids| !ids.is_modpack_file(file))
|
||||
})
|
||||
.collect();
|
||||
let user_files: Vec<(String, ProfileFile)> = if let Some(ids) = &modpack_ids
|
||||
{
|
||||
let filtered_files = profile
|
||||
.get_projects_excluding_modpack_files(
|
||||
&ids.hashes,
|
||||
&ids.project_ids,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await?;
|
||||
filtered_files.into_iter().collect()
|
||||
} else {
|
||||
let all_files = profile
|
||||
.get_projects(cache_behaviour, pool, fetch_semaphore)
|
||||
.await?;
|
||||
all_files.into_iter().collect()
|
||||
};
|
||||
|
||||
profile_files_to_content_items(
|
||||
let content_items = profile_files_to_content_items(
|
||||
&profile.path,
|
||||
&user_files,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
|
||||
Ok(content_items)
|
||||
}
|
||||
|
||||
/// Pre-fetched metadata for projects, versions, teams, and organizations.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user