From 3d7aea5a45fd0fc251610439a761f52e6657811f Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Thu, 7 May 2026 19:20:54 -0700 Subject: [PATCH] feat: add download metadata to website (#6034) * feat: add download metadata to website * add to project cards --- .../src/composables/useCdnDownloadContext.ts | 140 ++++++++++++++++++ apps/frontend/src/pages/[type]/[id].vue | 53 ++++++- .../src/pages/[type]/[id]/changelog.vue | 26 +++- .../pages/[type]/[id]/settings/versions.vue | 24 ++- .../pages/[type]/[id]/version/[version].vue | 31 +++- .../src/pages/[type]/[id]/versions.vue | 26 +++- .../src/pages/discover/[type]/index.vue | 20 ++- apps/frontend/src/pages/organization/[id].vue | 6 +- apps/frontend/src/utils/router.ts | 9 ++ .../src/components/version/VersionSummary.vue | 4 +- .../composables/use-browse-search.ts | 10 +- packages/ui/src/providers/project-page.ts | 8 +- packages/ui/src/utils/search.ts | 8 + 13 files changed, 335 insertions(+), 30 deletions(-) create mode 100644 apps/frontend/src/composables/useCdnDownloadContext.ts diff --git a/apps/frontend/src/composables/useCdnDownloadContext.ts b/apps/frontend/src/composables/useCdnDownloadContext.ts new file mode 100644 index 000000000..3f69a028b --- /dev/null +++ b/apps/frontend/src/composables/useCdnDownloadContext.ts @@ -0,0 +1,140 @@ +import type { FilterValue } from '@modrinth/ui' +import { LOADER_FILTER_TYPES } from '@modrinth/ui' + +const TEN_MINUTES = 600 + +export type DownloadContext = { + gameVersion?: string + loader?: string + reason?: 'standalone' | 'dependency' | 'modpack' | 'update' +} + +export type FilterSelection = { + gameVersion?: string + loader?: string +} + +const cookieDefaults = { + maxAge: TEN_MINUTES, + sameSite: 'lax' as const, + secure: true, + path: '/', + httpOnly: false, +} + +function readCookieValue(value: string | null | undefined): string | undefined { + if (typeof value !== 'string' || !value) { + return undefined + } + return value +} + +function newFilterSelection( + gameVersion: string | undefined, + loader: string | undefined, +): FilterSelection | null { + if (!gameVersion && !loader) { + return null + } else if (!gameVersion) { + return { + loader, + } + } else if (!loader) { + return { + gameVersion, + } + } else { + return { + gameVersion, + loader, + } + } +} + +export function useCdnDownloadContext() { + const filterGameVersionCookie = useCookie('mr_download_filter_game_version', { + ...cookieDefaults, + default: () => null, + }) + + const filterLoaderCookie = useCookie('mr_download_filter_loader', { + ...cookieDefaults, + default: () => null, + }) + + function createProjectDownloadUrl(originalUrl: string, context?: DownloadContext): string { + if (!originalUrl.startsWith('https://cdn.modrinth.com')) { + return originalUrl + } + + const reason = context?.reason + const gameVersion = context?.gameVersion ?? readCookieValue(filterGameVersionCookie.value) + const loader = context?.loader ?? readCookieValue(filterLoaderCookie.value) + + try { + const url = new URL(originalUrl) + + if (reason) { + url.searchParams.set('mr_download_reason', reason) + } + if (gameVersion) { + url.searchParams.set('mr_game_version', gameVersion) + } else { + url.searchParams.delete('mr_game_version') + } + + if (loader) { + url.searchParams.set('mr_loader', loader) + } else { + url.searchParams.delete('mr_loader') + } + return url.toString() + } catch { + return originalUrl + } + } + + function persistFilterSelection(selection: FilterSelection | null) { + if (!selection) { + filterGameVersionCookie.value = null + filterLoaderCookie.value = null + return + } + filterGameVersionCookie.value = selection.gameVersion ?? null + filterLoaderCookie.value = selection.loader ?? null + } + + function updateDiscoverFilterContext(filters: FilterValue[]) { + if (!import.meta.client) { + return + } + const versionFilters = [ + ...new Set(filters.filter((f) => f.type === 'game_version').map((f) => f.option)), + ] + const loaderFilters = [ + ...new Set( + filters + .filter((f) => (LOADER_FILTER_TYPES as readonly string[]).includes(f.type)) + .map((f) => f.option), + ), + ] + const gameVersion = versionFilters.length === 1 ? versionFilters[0] : undefined + const loader = loaderFilters.length === 1 ? loaderFilters[0] : undefined + persistFilterSelection(newFilterSelection(gameVersion, loader)) + } + + function updateVersionsFilterContext(gameVersions: string[], loaders: string[]) { + if (!import.meta.client) { + return + } + const gameVersion = gameVersions.length === 1 ? gameVersions[0] : undefined + const loader = loaders.length === 1 ? loaders[0] : undefined + persistFilterSelection(newFilterSelection(gameVersion, loader)) + } + + return { + createProjectDownloadUrl, + updateDiscoverFilterContext, + updateVersionsFilterContext, + } +} diff --git a/apps/frontend/src/pages/[type]/[id].vue b/apps/frontend/src/pages/[type]/[id].vue index f56440a58..9352ef0b0 100644 --- a/apps/frontend/src/pages/[type]/[id].vue +++ b/apps/frontend/src/pages/[type]/[id].vue @@ -370,18 +370,21 @@ @@ -1082,6 +1085,7 @@ import { OpenInAppModal, OverflowMenu, PopoutMenu, + PROJECT_DEP_MARKER_QUERY, ProjectBackgroundGradient, ProjectEnvironmentModal, ProjectHeader, @@ -1108,7 +1112,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { useLocalStorage } from '@vueuse/core' import dayjs from 'dayjs' import { Tooltip } from 'floating-vue' -import { nextTick, useTemplateRef, watch } from 'vue' +import { nextTick, readonly, ref, useTemplateRef, watch } from 'vue' import { navigateTo } from '#app' import Accordion from '~/components/ui/Accordion.vue' @@ -1137,6 +1141,7 @@ definePageMeta({ const data = useNuxtApp() const route = useRoute() +const router = useRouter() const signInRouteObj = computed(() => getSignInRouteObj(route)) const config = useRuntimeConfig() const moderationQueue = useModerationQueue() @@ -1146,6 +1151,23 @@ const { addNotification } = notifications const auth = await useAuth() const user = await useUser() +const { createProjectDownloadUrl } = useCdnDownloadContext() + +const downloadReason = ref('standalone') + +function absorbDepQuery() { + if (route.query.dep === PROJECT_DEP_MARKER_QUERY.dep) { + downloadReason.value = 'dependency' + if (import.meta.client) { + const newQuery = { ...route.query } + delete newQuery.dep + void router.replace({ path: route.path, query: newQuery, hash: route.hash }) + } + } +} + +watch(() => route.query.dep, absorbDepQuery, { immediate: true }) + const tags = useGeneratedState() const flags = useFeatureFlags() const cosmetics = useCosmetics() @@ -1210,6 +1232,14 @@ const currentPlatformText = computed(() => { return formatMessage(getTagMessage(currentPlatform.value, 'loader')) }) +function decorateModalDownloadUrl(url) { + return createProjectDownloadUrl(url, { + reason: downloadReason.value, + gameVersion: currentGameVersion.value ?? undefined, + loader: currentPlatform.value ?? undefined, + }) +} + const releaseVersions = computed(() => { const set = new Set() for (const gv of tags.value.gameVersions || []) { @@ -1715,15 +1745,27 @@ const serverRequiredContent = computed(() => { icon: content.project_icon, onclickName: content.project_id && content.project_id !== projectId.value - ? () => navigateTo(`/project/${content.project_id}`) + ? () => { + navigateTo({ + path: `/project/${content.project_id}`, + query: { ...PROJECT_DEP_MARKER_QUERY }, + }) + } : undefined, onclickVersion: content.project_id && content.project_id !== projectId.value - ? () => - navigateTo(`/project/${content.project_id}/version/${serverModpackVersion.value?.id}`) + ? () => { + navigateTo({ + path: `/project/${content.project_id}/version/${serverModpackVersion.value?.id}`, + query: { ...PROJECT_DEP_MARKER_QUERY }, + }) + } : undefined, onclickDownload: primaryFile?.url - ? () => navigateTo(primaryFile.url, { external: true }) + ? () => + navigateTo(createProjectDownloadUrl(primaryFile.url, { reason: 'dependency' }), { + external: true, + }) : undefined, showCustomModpackTooltip: content.project_id === projectId.value, } @@ -2703,6 +2745,7 @@ provideProjectPageContext({ // Lazy dependencies loading dependencies, dependenciesLoading: computed(() => dependenciesLoading.value), + cdnDownloadReason: readonly(downloadReason), // Invalidate all project queries (auto-refetches active ones) invalidate: invalidateProject, diff --git a/apps/frontend/src/pages/[type]/[id]/changelog.vue b/apps/frontend/src/pages/[type]/[id]/changelog.vue index 37dfa4aae..f5f34b6f6 100644 --- a/apps/frontend/src/pages/[type]/[id]/changelog.vue +++ b/apps/frontend/src/pages/[type]/[id]/changelog.vue @@ -56,7 +56,7 @@