feat: implement compatible version card and update download dependency buttons

This commit is contained in:
tdgao
2026-07-07 10:55:38 -07:00
parent 4a059b8a50
commit 8b1b9b8a0b
4 changed files with 525 additions and 274 deletions
@@ -80,107 +80,59 @@
</Combobox>
</div>
<div v-if="selectedVersion" class="flex flex-col gap-1">
<div class="flex flex-wrap items-center justify-between gap-2">
<h3 class="relative top-0.5 m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.compatibleVersionTitle) }}
</h3>
<ButtonStyled v-if="downloadAllFiles.length > 1" type="transparent">
<button :disabled="downloadingSelectedVersion" @click="downloadSelectedVersionFiles">
<SpinnerIcon v-if="downloadingSelectedVersion" aria-hidden="true" class="animate-spin" />
<DownloadIcon v-else aria-hidden="true" />
{{
formatMessage(
downloadingSelectedVersion
? messages.downloadingSelectedVersion
: messages.downloadAllSelectedVersion,
{
current: selectedVersionDownloadProgress.current,
total: selectedVersionDownloadProgress.total,
},
)
}}
</button>
</ButtonStyled>
</div>
<div
class="grid grid-cols-[1fr_min-content] items-center gap-3 rounded-2xl bg-surface-2 px-3 py-3"
<div v-if="selectedVersion" class="flex flex-col gap-2.5">
<h3
v-if="[...suggestedPreReleaseVersions, selectedVersion].length > 1"
class="relative top-0.5 m-0 text-base font-semibold text-contrast"
>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 items-center gap-2">
<nuxt-link
v-tooltip="truncatedTooltip(versionNumberRef, selectedVersion.version_number)"
:to="`/${project.project_type}/${project.slug || project.id}/version/${selectedVersion.id}`"
target="_blank"
rel="noopener noreferrer"
class="block min-w-0 text-contrast no-underline hover:underline"
>
<span ref="versionNumberRef" class="block truncate font-semibold">
{{ selectedVersion.version_number }}
</span>
</nuxt-link>
<VersionChannelTag
:channel="selectedVersion.version_type"
class="relative -top-px !py-0.5"
/>
</div>
<p
ref="versionNameRef"
v-tooltip="truncatedTooltip(versionNameRef, selectedVersion.name)"
class="m-0 w-fit max-w-full truncate text-sm text-secondary"
>
{{ selectedVersion.name }}
</p>
</div>
<ButtonStyled v-if="selectedPrimaryFile" color="brand" circular>
<a
v-tooltip="'Download'"
:href="selectedPrimaryFileDownloadUrl"
:download="selectedPrimaryFile.filename"
:aria-label="
formatMessage(messages.downloadVersion, {
version: selectedVersion.version_number,
})
"
@click="emit('download')"
>
<DownloadIcon aria-hidden="true" />
</a>
</ButtonStyled>
</div>
{{ formatMessage(messages.compatibleVersionTitle) }}
</h3>
<CompatibleVersionCard
:project="project"
:version="selectedVersion"
:download-reason="downloadReason"
:current-game-version="currentGameVersion"
:current-platform="currentPlatform"
:color="hasAdditionalDownloads ? 'standard' : 'brand'"
:type="hasAdditionalDownloads ? 'transparent' : 'standard'"
:circular="hasAdditionalDownloads"
@download="emit('download')"
/>
<CompatibleVersionCard
v-for="suggestedVersion in suggestedPreReleaseVersions"
:key="suggestedVersion.version.id"
:project="project"
:version="suggestedVersion.version"
:download-reason="downloadReason"
:current-game-version="currentGameVersion"
:current-platform="currentPlatform"
color="standard"
type="transparent"
circular
@download="emit('download')"
/>
</div>
<p v-else-if="currentPlatform && currentGameVersion && versions.length > 0">
{{
formatMessage(messages.noVersionsAvailable, {
gameVersion: currentGameVersion,
platform: currentPlatformText,
})
}}
</p>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, SpinnerIcon, TriangleAlertIcon } from '@modrinth/assets'
import { TriangleAlertIcon } from '@modrinth/assets'
import {
ButtonStyled,
type CdnDownloadReason,
Checkbox,
Combobox,
type ComboboxOption,
defineMessages,
getTagMessage,
injectNotificationManager,
truncatedTooltip,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import VersionChannelTag from '@modrinth/ui/src/components/version/VersionChannelTag.vue'
import type { DisplayProjectType } from '@modrinth/utils'
import dayjs from 'dayjs'
import JSZip from 'jszip'
import { computed, ref, watch } from 'vue'
import CompatibleVersionCard from './CompatibleVersionCard.vue'
defineOptions({
name: 'DownloadProject',
})
@@ -202,6 +154,10 @@ type DownloadableFile = {
filename: string
}
type SuggestedPreReleaseVersion = {
version: Labrinth.Versions.v3.Version
}
const props = withDefaults(
defineProps<{
project: DownloadModalProject
@@ -233,8 +189,6 @@ const emit = defineEmits<{
'update:selection': [selection: ProjectDownloadSelection]
}>()
const { formatMessage } = useVIntl()
const { createProjectDownloadUrl } = useCdnDownloadContext()
const { addNotification } = injectNotificationManager()
const debug = useDebugLogger('DownloadProject')
const tags = useGeneratedState()
@@ -242,13 +196,6 @@ const userSelectedGameVersion = ref<string | null>(props.initialGameVersion)
const userSelectedPlatform = ref<string | null>(props.initialPlatform)
const showAllVersions = ref(defaultShowAllVersions())
const versionFilter = ref('')
const versionNumberRef = ref<HTMLElement | null>(null)
const versionNameRef = ref<HTMLElement | null>(null)
const downloadingSelectedVersion = ref(false)
const selectedVersionDownloadProgress = ref({
current: 0,
total: 0,
})
const incompatibleGameVersionsSet = computed(() => new Set(props.incompatibleGameVersions))
const incompatibleLoadersSet = computed(() => new Set(props.incompatibleLoaders))
@@ -407,27 +354,15 @@ const filteredVersions = computed<Labrinth.Versions.v3.Version[]>(() => {
})
const filteredRelease = computed<Labrinth.Versions.v3.Version | undefined>(() => {
return filteredVersions.value.find((x) => x.version_type === 'release')
return latestVersionByType('release')
})
const filteredBeta = computed<Labrinth.Versions.v3.Version | undefined>(() => {
return filteredVersions.value.find(
(x) =>
x.version_type === 'beta' &&
(!filteredRelease.value ||
dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))),
)
return latestVersionByType('beta')
})
const filteredAlpha = computed<Labrinth.Versions.v3.Version | undefined>(() => {
return filteredVersions.value.find(
(x) =>
x.version_type === 'alpha' &&
(!filteredRelease.value ||
dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))) &&
(!filteredBeta.value ||
dayjs(x.date_published).isAfter(dayjs(filteredBeta.value.date_published))),
)
return latestVersionByType('alpha')
})
const selectedVersion = computed<Labrinth.Versions.v3.Version | null>(() => {
@@ -442,39 +377,40 @@ const selectedPrimaryFile = computed<Labrinth.Versions.v3.VersionFile | null>(()
)
})
const selectedPrimaryFileDownloadUrl = computed(() => {
if (!selectedPrimaryFile.value) return '#'
return getDownloadUrl(selectedPrimaryFile.value.url)
})
const suggestedPreReleaseVersions = computed<SuggestedPreReleaseVersion[]>(() => {
if (!selectedVersion.value || selectedVersion.value.version_type !== 'release') return []
const selectedVersionDownloadFiles = computed(() => {
if (!selectedVersion.value) return []
return selectedVersion.value.files.map((file) => ({
href: getDownloadUrl(file.url),
filename: file.filename,
}))
})
const downloadAllFiles = computed(() => {
const files: DownloadableFile[] = []
const hrefs = new Set<string>()
for (const file of [...selectedVersionDownloadFiles.value, ...props.dependencyDownloadFiles]) {
if (hrefs.has(file.href)) continue
hrefs.add(file.href)
files.push(file)
const versions: SuggestedPreReleaseVersion[] = []
const beta = filteredBeta.value
if (beta && isNewerThan(beta, selectedVersion.value)) {
versions.push({
version: beta,
})
}
return files
const alpha = filteredAlpha.value
if (alpha && isNewerThan(alpha, selectedVersion.value)) {
versions.push({
version: alpha,
})
}
return versions
})
const selectedVersionZipFilename = computed(() => {
if (!selectedVersion.value) return `${sanitizeFilename(props.project.title)}.zip`
const hasAdditionalDownloads = computed(() => {
const hrefs = new Set<string>()
return `${sanitizeFilename(props.project.title)} ${sanitizeFilename(
selectedVersion.value.version_number,
)}.zip`
for (const file of selectedVersion.value?.files ?? []) {
hrefs.add(file.url)
}
for (const file of props.dependencyDownloadFiles) {
if (hrefs.has(file.href)) continue
hrefs.add(file.href)
}
return hrefs.size > 1
})
watch(
@@ -513,109 +449,20 @@ function selectPlatform(platform?: string) {
emit('selectPlatform', platform)
}
function getDownloadUrl(url: string) {
return createProjectDownloadUrl(url, {
reason: props.downloadReason,
gameVersion: currentGameVersion.value ?? undefined,
loader: currentPlatform.value ?? undefined,
})
function latestVersionByType(type: Labrinth.Versions.v3.VersionChannel) {
return filteredVersions.value
.filter((version) => version.version_type === type)
.reduce<Labrinth.Versions.v3.Version | undefined>((latest, version) => {
if (!latest || isNewerThan(version, latest)) return version
return latest
}, undefined)
}
async function downloadSelectedVersionFiles() {
if (downloadingSelectedVersion.value || downloadAllFiles.value.length <= 1) return
downloadingSelectedVersion.value = true
const files = [...downloadAllFiles.value]
selectedVersionDownloadProgress.value = {
current: 0,
total: files.length,
}
try {
const zip = new JSZip()
const usedFilenames = new Set<string>()
for (const [index, file] of files.entries()) {
selectedVersionDownloadProgress.value = {
current: index + 1,
total: files.length,
}
const response = await fetch(file.href)
if (!response.ok) {
throw new Error(`Failed to download ${file.filename}`)
}
zip.file(uniqueFilename(file.filename, usedFilenames), await response.blob())
}
downloadBlob(
await zip.generateAsync({
type: 'blob',
mimeType: 'application/zip',
}),
selectedVersionZipFilename.value,
)
emit('download')
} catch (error) {
console.error('Failed to download selected version files:', error)
addNotification({
title: formatMessage(messages.downloadSelectedVersionFailedTitle),
text: formatMessage(messages.downloadSelectedVersionFailedText),
type: 'error',
})
} finally {
downloadingSelectedVersion.value = false
selectedVersionDownloadProgress.value = {
current: 0,
total: 0,
}
}
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(url), 0)
}
function sanitizeFilename(value: string) {
const sanitized = value
.replace(/[<>:"/\\|?*]/g, '')
.replace(/\s+/g, ' ')
.trim()
return sanitized || 'download'
}
function uniqueFilename(filename: string, usedFilenames: Set<string>) {
const sanitizedFilename = sanitizeFilename(filename)
if (!usedFilenames.has(sanitizedFilename)) {
usedFilenames.add(sanitizedFilename)
return sanitizedFilename
}
const extensionIndex = sanitizedFilename.lastIndexOf('.')
const basename =
extensionIndex > 0 ? sanitizedFilename.slice(0, extensionIndex) : sanitizedFilename
const extension = extensionIndex > 0 ? sanitizedFilename.slice(extensionIndex) : ''
let index = 2
let candidate = `${basename} (${index})${extension}`
while (usedFilenames.has(candidate)) {
index += 1
candidate = `${basename} (${index})${extension}`
}
usedFilenames.add(candidate)
return candidate
function isNewerThan(
version: Labrinth.Versions.v3.Version,
comparison: Labrinth.Versions.v3.Version,
) {
return dayjs(version.date_published).isAfter(dayjs(comparison.date_published))
}
function loaderLabel(loader: string) {
@@ -706,38 +553,14 @@ const messages = defineMessages({
id: 'project.download.game-version-unsupported-tooltip',
defaultMessage: '{title} does not support {gameVersion} for {platform}',
},
downloadVersion: {
id: 'project.download.download-version',
defaultMessage: 'Download {version}',
},
compatibleVersionTitle: {
id: 'project.download.compatible-version-title',
defaultMessage: 'Compatible version',
},
downloadAllSelectedVersion: {
id: 'project.download.selected-version-download-all',
defaultMessage: 'Download all (.zip)',
},
downloadingSelectedVersion: {
id: 'project.download.selected-version-downloading',
defaultMessage: 'Downloading... ({current}/{total})',
},
downloadSelectedVersionFailedTitle: {
id: 'project.download.selected-version-failed-title',
defaultMessage: 'Could not download version',
},
downloadSelectedVersionFailedText: {
id: 'project.download.selected-version-failed-text',
defaultMessage: 'One or more version files could not be downloaded. Please try again.',
defaultMessage: 'Compatible versions',
},
noGameVersionsFound: {
id: 'project.download.no-game-versions-found',
defaultMessage: 'No game versions found',
},
noVersionsAvailable: {
id: 'project.download.no-versions-available',
defaultMessage: 'No versions available for {gameVersion} and {platform}.',
},
platformUnsupportedTooltip: {
id: 'project.download.platform-unsupported-tooltip',
defaultMessage: '{title} does not support {platform} for {gameVersion}',