feat: add download metadata to website (#6034)

* feat: add download metadata to website

* add to project cards
This commit is contained in:
Prospector
2026-05-07 19:20:54 -07:00
committed by GitHub
parent fd5d2797b3
commit 3d7aea5a45
13 changed files with 335 additions and 30 deletions

View File

@@ -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<string | null>('mr_download_filter_game_version', {
...cookieDefaults,
default: () => null,
})
const filterLoaderCookie = useCookie<string | null>('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,
}
}

View File

@@ -370,18 +370,21 @@
<VersionSummary
v-if="filteredRelease"
:version="filteredRelease"
:decorate-download-url="decorateModalDownloadUrl"
@on-download="onDownload"
@on-navigate="onVersionNavigate"
/>
<VersionSummary
v-if="filteredBeta"
:version="filteredBeta"
:decorate-download-url="decorateModalDownloadUrl"
@on-download="onDownload"
@on-navigate="onVersionNavigate"
/>
<VersionSummary
v-if="filteredAlpha"
:version="filteredAlpha"
:decorate-download-url="decorateModalDownloadUrl"
@on-download="onDownload"
@on-navigate="onVersionNavigate"
/>
@@ -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,

View File

@@ -56,7 +56,7 @@
<ButtonStyled color="brand" type="transparent">
<a
class="ml-auto"
:href="version.primaryFile?.url"
:href="createDownloadUrl(version)"
:title="`Download ${version.name}`"
>
<DownloadIcon aria-hidden="true" />
@@ -98,7 +98,9 @@ import {
import VersionFilterControl from '@modrinth/ui/src/components/version/VersionFilterControl.vue'
import { renderHighlightedString } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { onMounted } from 'vue'
import { onMounted, watch } from 'vue'
const { createProjectDownloadUrl, updateVersionsFilterContext } = useCdnDownloadContext()
const formatDate = useFormatDateTime({
month: 'short',
@@ -106,7 +108,8 @@ const formatDate = useFormatDateTime({
year: 'numeric',
})
const { projectV2, versions, versionsLoading, loadVersions } = injectProjectPageContext()
const { projectV2, versions, versionsLoading, loadVersions, cdnDownloadReason } =
injectProjectPageContext()
// Load versions on mount (client-side)
onMounted(() => {
@@ -216,6 +219,23 @@ function updateQuery(newQueries) {
},
})
}
watch(
() => [route.query.g, route.query.l],
() => {
updateVersionsFilterContext(
queryAsStringArray(route.query.g),
queryAsStringArray(route.query.l),
)
},
{ immediate: true },
)
function createDownloadUrl(version) {
return createProjectDownloadUrl(getPrimaryFile(version).url, {
reason: cdnDownloadReason.value,
})
}
</script>
<style lang="scss">

View File

@@ -110,7 +110,7 @@
id: 'download',
color: 'primary',
hoverFilled: true,
link: getPrimaryFile(version).url,
link: createDownloadUrl(version),
action: () => {
emit('onDownload')
},
@@ -340,7 +340,7 @@ import {
ProjectPageVersions,
useVIntl,
} from '@modrinth/ui'
import { useTemplateRef } from 'vue'
import { useTemplateRef, watch } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
@@ -348,6 +348,8 @@ import { reportVersion } from '~/utils/report-helpers.ts'
const route = useRoute()
const { createProjectDownloadUrl, updateVersionsFilterContext } = useCdnDownloadContext()
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -357,6 +359,7 @@ const {
versions,
invalidate,
loadVersions,
cdnDownloadReason,
} = injectProjectPageContext()
// Load versions on mount (client-side)
@@ -401,6 +404,23 @@ function getPrimaryFile(version: Labrinth.Versions.v3.Version) {
return version.files.find((x) => x.primary) || version.files[0]
}
watch(
() => [route.query.g, route.query.l],
() => {
updateVersionsFilterContext(
queryAsStringArray(route.query.g),
queryAsStringArray(route.query.l),
)
},
{ immediate: true },
)
function createDownloadUrl(version: Labrinth.Versions.v3.Version) {
return createProjectDownloadUrl(getPrimaryFile(version).url, {
reason: cdnDownloadReason.value,
})
}
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text)
}

View File

@@ -139,7 +139,7 @@
<ButtonStyled v-if="primaryFile && !currentMember" color="brand">
<a
v-tooltip="primaryFile.filename + ' (' + formatBytes(primaryFile.size) + ')'"
:href="primaryFile.url"
:href="decoratedPrimaryFileUrl"
@click="emit('onDownload')"
>
<DownloadIcon aria-hidden="true" />
@@ -213,14 +213,19 @@
:key="index"
class="dependency"
:class="{ 'button-transparent': !isEditing }"
@click="!isEditing ? router.push(dependency.link) : {}"
@click="!isEditing ? navigateToDependency(dependency) : {}"
>
<Avatar
:src="dependency.project ? dependency.project.icon_url : null"
alt="dependency-icon"
size="sm"
/>
<nuxt-link v-if="!isEditing" :to="dependency.link" class="info">
<nuxt-link
v-if="!isEditing"
:to="{ path: dependency.link, query: PROJECT_DEP_MARKER_QUERY }"
class="info"
@click.stop
>
<span class="project-title">
{{ dependency.project ? dependency.project.title : 'Unknown Project' }}
</span>
@@ -299,7 +304,7 @@
</span>
<ButtonStyled>
<a
:href="file.url"
:href="decorateDownloadUrl(file.url)"
class="raised-button"
:title="`Download ${file.filename}`"
tabindex="0"
@@ -435,6 +440,7 @@ import {
injectNotificationManager,
injectProjectPageContext,
MultiSelect,
PROJECT_DEP_MARKER_QUERY,
StyledInput,
useFormatDateTime,
} from '@modrinth/ui'
@@ -461,6 +467,7 @@ const auth = await useAuth()
const tags = useGeneratedState()
const flags = useFeatureFlags()
const { addNotification } = injectNotificationManager()
const { createProjectDownloadUrl } = useCdnDownloadContext()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
@@ -481,6 +488,7 @@ const {
dependenciesLoading: contextDependenciesLoading,
loadDependencies,
invalidate,
cdnDownloadReason,
} = injectProjectPageContext()
// Load versions and dependencies in parallel
@@ -752,6 +760,21 @@ const sortedDeps = computed(() => {
)
})
const decoratedPrimaryFileUrl = computed(() =>
createProjectDownloadUrl(primaryFile.value?.url, { reason: cdnDownloadReason.value }),
)
function decorateDownloadUrl(url: string) {
return createProjectDownloadUrl(url, { reason: cdnDownloadReason.value })
}
function navigateToDependency(dependency: { link: string }) {
return router.push({
path: dependency.link,
query: { ...PROJECT_DEP_MARKER_QUERY },
})
}
const environment = computed(
() => ENVIRONMENTS_COPY[version.value.environment as keyof typeof ENVIRONMENTS_COPY],
)

View File

@@ -46,7 +46,7 @@
<ButtonStyled circular type="transparent">
<a
v-tooltip="`Download`"
:href="getPrimaryFile(version).url"
:href="createDownloadUrl(version)"
class="hover:!bg-button-bg [&>svg]:!text-green"
aria-label="Download"
@click="emit('onDownload')"
@@ -100,7 +100,7 @@
id: 'download',
color: 'primary',
hoverFilled: true,
link: getPrimaryFile(version).url,
link: createDownloadUrl(version),
action: () => {
emit('onDownload')
},
@@ -266,7 +266,7 @@ import {
OverflowMenu,
ProjectPageVersions,
} from '@modrinth/ui'
import { onMounted, useTemplateRef } from 'vue'
import { onMounted, useTemplateRef, watch } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
@@ -274,6 +274,8 @@ import { reportVersion } from '~/utils/report-helpers.ts'
const route = useRoute()
const { createProjectDownloadUrl, updateVersionsFilterContext } = useCdnDownloadContext()
const tags = useGeneratedState()
const flags = useFeatureFlags()
const auth = await useAuth()
@@ -287,6 +289,7 @@ const {
versions,
versionsLoading,
loadVersions,
cdnDownloadReason,
} = injectProjectPageContext()
// Load versions on mount (client-side)
@@ -316,6 +319,23 @@ function getPrimaryFile(version) {
return version.files.find((x) => x.primary) || version.files[0]
}
watch(
() => [route.query.g, route.query.l],
() => {
updateVersionsFilterContext(
queryAsStringArray(route.query.g),
queryAsStringArray(route.query.l),
)
},
{ immediate: true },
)
function createDownloadUrl(version) {
return createProjectDownloadUrl(getPrimaryFile(version).url, {
reason: cdnDownloadReason.value,
})
}
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
}

View File

@@ -17,6 +17,7 @@ import {
BrowsePageLayout,
BrowseSidebar,
CreationFlowModal,
PROJECT_DEP_MARKER_QUERY,
defineMessages,
injectModrinthClient,
injectNotificationManager,
@@ -40,6 +41,8 @@ import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('Discover')
const { updateDiscoverFilterContext } = useCdnDownloadContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
@@ -401,7 +404,13 @@ function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject
name: project_name,
icon: project_icon ?? undefined,
onclick:
project_id !== project.project_id ? () => navigateTo(`/project/${project_id}`) : undefined,
project_id !== project.project_id
? () =>
navigateTo({
path: `/project/${project_id}`,
query: { ...PROJECT_DEP_MARKER_QUERY },
})
: undefined,
showCustomModpackTooltip: project_id === project.project_id,
}
}
@@ -639,6 +648,15 @@ const searchState = useBrowseSearch({
displayMode: resultsDisplayMode,
})
watch(
() =>
searchState.isServerType.value
? searchState.serverCurrentFilters.value
: searchState.currentFilters.value,
(filters) => updateDiscoverFilterContext(filters),
{ deep: true, immediate: true },
)
watch(
[
() => searchState.query.value,

View File

@@ -307,6 +307,7 @@ import {
injectModrinthClient,
NavTabs,
OverflowMenu,
PROJECT_DEP_MARKER_QUERY,
ProjectCard,
ProjectCardList,
useCompactNumber,
@@ -489,7 +490,10 @@ function getServerModpackContent(project: ProjectV3) {
onclick:
project_id !== project.id
? () => {
navigateTo(`/project/${project_id}`)
navigateTo({
path: `/project/${project_id}`,
query: { ...PROJECT_DEP_MARKER_QUERY },
})
}
: undefined,
showCustomModpackTooltip: project_id === project.id,

View File

@@ -8,6 +8,15 @@ export function queryAsString(query: LocationQueryValue | LocationQueryValue[]):
return Array.isArray(query) ? (query[0] ?? null) : (query ?? null)
}
export function queryAsStringArray(
query: LocationQueryValue | LocationQueryValue[],
): string | null {
if (query === undefined || query === null) {
return []
}
return Array.isArray(query) ? query.map(String) : [String(query)]
}
export function routeNameAsString(name: RouteRecordNameGeneric | undefined): string | undefined {
return name && typeof name === 'string' ? (name as string) : undefined
}

View File

@@ -39,11 +39,13 @@ import { ButtonStyled, VersionChannelIndicator } from '../index'
const props = defineProps<{
version: Version
decorateDownloadUrl?: (url: string) => string
}>()
const downloadUrl = computed(() => {
const primary: VersionFile = props.version.files.find((x) => x.primary) || props.version.files[0]
return primary.url
const raw = primary.url
return props.decorateDownloadUrl ? props.decorateDownloadUrl(raw) : raw
})
const emit = defineEmits<{

View File

@@ -5,7 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useDebugLogger } from '#ui/composables/debug-logger'
import type { FilterType, FilterValue, ProjectType, SortType } from '#ui/utils/search'
import { useSearch } from '#ui/utils/search'
import { LOADER_FILTER_TYPES, useSearch } from '#ui/utils/search'
import { useServerSearch } from '#ui/utils/server-search'
import type { BrowseSearchResponse } from '../types'
@@ -60,14 +60,6 @@ export interface BrowseSearchState {
onFilterChange: () => void
}
const LOADER_FILTER_TYPES = [
'mod_loader',
'plugin_loader',
'modpack_loader',
'shader_loader',
'plugin_platform',
] as const
export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchState {
const debug = useDebugLogger('BrowseSearch')
const route = useRoute()

View File

@@ -1,8 +1,12 @@
import type { Labrinth } from '@modrinth/api-client'
import type { Ref } from 'vue'
import type { DeepReadonly, Ref } from 'vue'
import { createContext } from '.'
export const PROJECT_DEP_MARKER_QUERY = { dep: '1' } as const
export type CdnDownloadReason = 'standalone' | 'dependency'
export interface ProjectPageContext {
// Data refs
projectV2: Ref<Labrinth.Projects.v2.Project>
@@ -17,6 +21,8 @@ export interface ProjectPageContext {
dependencies: Ref<Labrinth.Projects.v2.DependencyInfo | null>
dependenciesLoading: Ref<boolean>
cdnDownloadReason: DeepReadonly<Ref<CdnDownloadReason>>
// Invalidate all project queries (auto-refetches active ones)
invalidate: () => Promise<void>

View File

@@ -59,6 +59,14 @@ export type FilterValue = {
negative?: boolean
}
export const LOADER_FILTER_TYPES = [
'mod_loader',
'plugin_loader',
'modpack_loader',
'shader_loader',
'plugin_platform',
] as const
export interface GameVersion {
version: string
version_type: 'release' | 'snapshot' | 'alpha' | 'beta'