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
@@ -0,0 +1,144 @@
<template>
<div
class="grid grid-cols-[1fr_min-content] items-center gap-3 rounded-2xl bg-surface-2 px-3 py-3"
>
<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, version.version_number)"
:to="`/${project.project_type}/${project.slug || project.id}/version/${version.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">
{{ version.version_number }}
</span>
</nuxt-link>
<VersionChannelTag :channel="version.version_type" class="relative -top-px !py-1" />
</div>
<div class="flex min-w-0 items-center gap-1.5 text-sm text-secondary">
<span v-tooltip="publishedTooltip" class="min-w-0 truncate capitalize">
{{ publishedLabel }}
</span>
<div v-if="primaryFile" class="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-surface-5"></div>
<span v-if="primaryFile" class="flex-shrink-0">
{{ primaryFileSizeLabel }}
</span>
</div>
</div>
<ButtonStyled v-if="primaryFile" :color="color" :type="type" :circular="circular">
<a
v-tooltip="circular ? formatMessage(messages.download) : null"
:href="primaryFileDownloadUrl"
:download="primaryFile.filename"
:aria-label="
formatMessage(messages.downloadVersion, {
version: version.version_number,
})
"
@click="emit('download')"
>
<DownloadIcon aria-hidden="true" />
<template v-if="!circular">
{{ formatMessage(messages.download) }}
</template>
</a>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon } from '@modrinth/assets'
import {
ButtonStyled,
type CdnDownloadReason,
defineMessages,
truncatedTooltip,
useFormatBytes,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import VersionChannelTag from '@modrinth/ui/src/components/version/VersionChannelTag.vue'
import type { DisplayProjectType } from '@modrinth/utils'
import { computed, ref } from 'vue'
defineOptions({
name: 'CompatibleVersionCard',
})
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
project_type: DisplayProjectType
actualProjectType: Labrinth.Projects.v2.ProjectType
}
const props = withDefaults(
defineProps<{
project: DownloadModalProject
version: Labrinth.Versions.v3.Version
downloadReason?: CdnDownloadReason
currentGameVersion?: string | null
currentPlatform?: string | null
color?: 'brand' | 'standard'
type?: 'standard' | 'transparent'
circular?: boolean
}>(),
{
downloadReason: 'standalone',
currentGameVersion: null,
currentPlatform: null,
color: 'brand',
type: 'standard',
circular: false,
},
)
const emit = defineEmits<{
download: []
}>()
const { createProjectDownloadUrl } = useCdnDownloadContext()
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatRelativeTime = useRelativeTime()
const versionNumberRef = ref<HTMLElement | null>(null)
const primaryFile = computed<Labrinth.Versions.v3.VersionFile | null>(() => {
return props.version.files?.find((file) => file.primary) || props.version.files?.[0] || null
})
const primaryFileDownloadUrl = computed(() => {
if (!primaryFile.value) return '#'
return createProjectDownloadUrl(primaryFile.value.url, {
reason: props.downloadReason,
gameVersion: props.currentGameVersion ?? undefined,
loader: props.currentPlatform ?? undefined,
})
})
const publishedLabel = computed(() => formatRelativeTime(props.version.date_published))
const publishedTooltip = computed(() => formatDateTime(props.version.date_published))
const primaryFileSizeLabel = computed(() => {
if (!primaryFile.value) return ''
return formatBytes(primaryFile.value.size)
})
const messages = defineMessages({
downloadVersion: {
id: 'project.download.download-version',
defaultMessage: 'Download {version}',
},
download: {
id: 'project.download.download',
defaultMessage: 'Download',
},
})
</script>
@@ -95,6 +95,7 @@ const props = withDefaults(
const emit = defineEmits<{
download: []
'update:downloadable-files': [files: DownloadableDependencyFile[]]
'update:downloadable-files-loaded': [loaded: boolean]
}>()
const client = injectModrinthClient()
const { createProjectDownloadUrl } = useCdnDownloadContext()
@@ -109,7 +110,7 @@ const dependencyResolutionPreferences = computed<Labrinth.Content.v3.ResolutionP
loaders: props.currentPlatform ? [props.currentPlatform] : props.selectedVersion?.loaders || [],
}))
const { data: dependencyResolution } = useQuery({
const { data: dependencyResolution, isFetching: dependencyResolutionFetching } = useQuery({
queryKey: computed(() => [
'project-download-modal',
'content-resolve',
@@ -147,7 +148,7 @@ const dependencyVersionIds = computed<string[]>(() => {
]
})
const { data: dependencyVersions } = useQuery({
const { data: dependencyVersions, isFetching: dependencyVersionsFetching } = useQuery({
queryKey: computed(() => [
'project-download-modal',
'resolved-versions',
@@ -176,7 +177,7 @@ const dependencyProjectIds = computed<string[]>(() => {
]
})
const { data: dependencyProjects } = useQuery({
const { data: dependencyProjects, isFetching: dependencyProjectsFetching } = useQuery({
queryKey: computed(() => [
'project-download-modal',
'resolved-projects',
@@ -210,6 +211,7 @@ const dependenciesByParentVersionId = computed(() => {
const dependenciesLoaded = computed(() => {
if (!shouldResolveDependencies.value) return false
if (dependencyResolutionFetching.value) return false
if (!dependencyResolution.value) return false
if (
dependencyResolution.value.primary.version_id &&
@@ -218,11 +220,13 @@ const dependenciesLoaded = computed(() => {
return false
}
if (
dependencyVersionsFetching.value ||
!dependencyVersionIds.value.every((versionId) => dependencyVersionById.value.has(versionId))
) {
return false
}
if (
dependencyProjectsFetching.value ||
!dependencyProjectIds.value.every((projectId) => dependencyProjectById.value.has(projectId))
) {
return false
@@ -288,6 +292,12 @@ const downloadableDependencyFiles = computed<DownloadableDependencyFile[]>(() =>
collectDownloadableDependencyFiles(visibleDependencyRows.value),
)
const downloadableDependencyFilesLoaded = computed(() => {
if (props.dependencies) return true
if (!shouldResolveDependencies.value) return false
return dependenciesLoaded.value
})
watch(
downloadableDependencyFiles,
(files) => {
@@ -296,6 +306,14 @@ watch(
{ immediate: true },
)
watch(
downloadableDependencyFilesLoaded,
(loaded) => {
emit('update:downloadable-files-loaded', loaded)
},
{ immediate: true },
)
function primaryFileForVersion(version?: Labrinth.Versions.v3.Version) {
return version?.files?.find((file) => file.primary) || version?.files?.[0]
}
@@ -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}',
@@ -1,5 +1,5 @@
<template>
<NewModal ref="modal" :on-show="onShow" :on-hide="onHide" width="544px">
<NewModal ref="modal" :on-show="onShow" :on-hide="onHide" width="544px" actions-divider>
<template #title>
<template v-if="project">
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
@@ -27,7 +27,7 @@
:reset-key="downloadProjectResetKey"
@select-game-version="selectGameVersion"
@select-platform="selectPlatform"
@update:selection="projectDownloadSelection = $event"
@update:selection="updateProjectDownloadSelection"
@download="onDownload"
/>
<div class="flex flex-col gap-4">
@@ -39,6 +39,7 @@
:download-reason="downloadReason"
:additional-files="additionalFiles"
@update:downloadable-files="dependencyDownloadFiles = $event"
@update:downloadable-files-loaded="dependencyDownloadFilesLoaded = $event"
@download="onDownload"
/>
</div>
@@ -54,16 +55,48 @@
/>
</div>
</template>
<template v-if="showDependencyDownloadActions" #actions>
<div class="flex flex-wrap justify-end gap-2">
<ButtonStyled>
<button :disabled="!!downloadingActionType" @click="downloadSelectedVersionZip">
<SpinnerIcon
v-if="downloadingActionType === 'zip'"
aria-hidden="true"
class="animate-spin"
/>
<DownloadIcon v-else aria-hidden="true" />
{{ formatMessage(messages.downloadAsZip) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
:disabled="!!downloadingActionType || !dependencyDownloadFilesLoaded"
@click="downloadSelectedVersionFilesWithDependencies"
>
<SpinnerIcon
v-if="downloadingActionType === 'dependencies'"
aria-hidden="true"
class="animate-spin"
/>
<DownloadIcon v-else aria-hidden="true" />
{{ formatMessage(messages.downloadWithDependencies) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
type CdnDownloadReason,
defineMessages,
injectModrinthClient,
injectNotificationManager,
NewModal,
ServersPromo,
truncatedTooltip,
@@ -72,6 +105,7 @@ import {
} from '@modrinth/ui'
import type { DisplayProjectType } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import JSZip from 'jszip'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { navigateTo } from '#app'
@@ -99,6 +133,12 @@ type DownloadableFile = {
filename: string
}
type DownloadedFile = DownloadableFile & {
blob: Blob
}
type DownloadActionType = 'zip' | 'dependencies'
type NewModalRef = {
show: (event?: MouseEvent) => void
hide: () => void
@@ -138,6 +178,8 @@ const route = useRoute()
const flags = useFeatureFlags()
const tags = useGeneratedState()
const client = injectModrinthClient()
const { createProjectDownloadUrl } = useCdnDownloadContext()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const debug = useDebugLogger('DownloadModal')
@@ -148,8 +190,15 @@ const showProjectId = ref<string | null>(null)
const showOptions = ref<ResolvedProjectDownloadModalShowOptions>(getDefaultShowOptions())
const downloadProjectResetKey = ref(0)
const projectDownloadSelection = ref<ProjectDownloadSelection>(getDefaultProjectDownloadSelection())
const pendingRouteSelection = ref({
gameVersion: getStringQueryValue(route.query.version),
platform: getStringQueryValue(route.query.loader),
})
const dependencyDownloadFiles = ref<DownloadableFile[]>([])
const dependencyDownloadFilesLoaded = ref(false)
const downloadingActionType = ref<DownloadActionType | null>(null)
const MODAL_CLOSE_STATE_RESET_MS = 350
const DOWNLOAD_URL_REVOKE_MS = 60000
let closeStateResetTimeout: ReturnType<typeof setTimeout> | null = null
const routeProjectId = computed(() => showProjectId.value ?? props.projectId ?? null)
@@ -183,11 +232,7 @@ const downloadTitle = computed(() => {
})
const versionsEnabled = ref(false)
const {
data: versionsV3,
error: _versionsV3Error,
isFetching: versionsV3Loading,
} = useQuery({
const { data: versionsV3, isFetching: versionsV3Loading } = useQuery({
queryKey: computed(() => ['project', resolvedProjectId.value, 'versions', 'v3']),
queryFn: () =>
client.labrinth.versions_v3.getProjectVersions(resolvedProjectId.value!, {
@@ -238,6 +283,23 @@ const additionalFiles = computed(() => {
return selectedVersion.value.files.filter((file) => file !== selectedPrimaryFile.value)
})
const selectedVersionDownloadFiles = computed<DownloadableFile[]>(() => {
if (!selectedVersion.value) return []
return selectedVersion.value.files.map((file) => ({
href: createProjectDownloadUrl(file.url, {
reason: props.downloadReason,
gameVersion: currentGameVersion.value ?? undefined,
loader: currentPlatform.value ?? undefined,
}),
filename: file.filename,
}))
})
const showDependencyDownloadActions = computed(
() => dependencyDownloadFiles.value.length > 0 && selectedVersionDownloadFiles.value.length > 0,
)
watch(projectV2Error, (error) => {
if (error) {
debug('project query failed', error)
@@ -249,6 +311,22 @@ const messages = defineMessages({
id: 'project.download.title',
defaultMessage: 'Download {title}',
},
downloadAsZip: {
id: 'project.download.download-as-zip',
defaultMessage: 'Download as .zip',
},
downloadWithDependencies: {
id: 'project.download.download-with-dependencies',
defaultMessage: 'Download with deps',
},
downloadZipFailedTitle: {
id: 'project.download.zip-failed-title',
defaultMessage: 'Could not download files',
},
downloadZipFailedText: {
id: 'project.download.zip-failed-text',
defaultMessage: 'One or more files could not be downloaded. Please try again.',
},
})
function getProjectTypeForUrl(
@@ -278,15 +356,27 @@ function updateDownloadQuery({
platform: string | null
}) {
if (!props.updateRouteSelection) return
const nextGameVersion =
gameVersion ??
pendingRouteSelection.value.gameVersion ??
getStringQueryValue(route.query.version)
const nextPlatform =
platform ?? pendingRouteSelection.value.platform ?? getStringQueryValue(route.query.loader)
pendingRouteSelection.value = {
gameVersion: nextGameVersion,
platform: nextPlatform,
}
navigateTo(
{
query: {
...route.query,
...(gameVersion && {
version: gameVersion,
...(nextGameVersion && {
version: nextGameVersion,
}),
...(platform && {
loader: platform,
...(nextPlatform && {
loader: nextPlatform,
}),
},
hash: route.hash,
@@ -298,17 +388,35 @@ function updateDownloadQuery({
function selectGameVersion(gameVersion: string) {
updateDownloadQuery({
gameVersion,
platform: currentPlatform.value,
platform: null,
})
}
function selectPlatform(platform: string) {
updateDownloadQuery({
gameVersion: currentGameVersion.value,
gameVersion: null,
platform,
})
}
function updateProjectDownloadSelection(selection: ProjectDownloadSelection) {
const previousSelection = projectDownloadSelection.value
if (
previousSelection.selectedVersion?.id !== selection.selectedVersion?.id ||
previousSelection.currentGameVersion !== selection.currentGameVersion ||
previousSelection.currentPlatform !== selection.currentPlatform
) {
dependencyDownloadFiles.value = []
dependencyDownloadFilesLoaded.value = false
}
projectDownloadSelection.value = selection
pendingRouteSelection.value = {
gameVersion: selection.currentGameVersion,
platform: selection.currentPlatform,
}
}
function onShow() {
clearCloseStateResetTimeout()
modalOpen.value = true
@@ -361,6 +469,158 @@ function onDownload() {
emit('download')
}
async function downloadSelectedVersionZip() {
if (downloadingActionType.value) return
downloadingActionType.value = 'zip'
const files = dedupeDownloadFiles([
...selectedVersionDownloadFiles.value,
...dependencyDownloadFiles.value,
])
try {
const zip = new JSZip()
const usedFilenames = new Set<string>()
for (const file of files) {
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(),
)
emit('download')
} catch (error) {
console.error('Failed to download selected version files:', error)
addNotification({
title: formatMessage(messages.downloadZipFailedTitle),
text: formatMessage(messages.downloadZipFailedText),
type: 'error',
})
} finally {
downloadingActionType.value = null
}
}
async function downloadSelectedVersionFilesWithDependencies() {
if (downloadingActionType.value || !dependencyDownloadFilesLoaded.value) return
downloadingActionType.value = 'dependencies'
try {
const files = dedupeDownloadFiles([
...selectedVersionDownloadFiles.value,
...dependencyDownloadFiles.value,
])
const downloadedFiles = await Promise.all(files.map(downloadFileBlob))
for (const file of downloadedFiles) {
downloadBlob(file.blob, file.filename)
}
emit('download')
} catch (error) {
console.error('Failed to download selected version files:', error)
addNotification({
title: formatMessage(messages.downloadZipFailedTitle),
text: formatMessage(messages.downloadZipFailedText),
type: 'error',
})
} finally {
downloadingActionType.value = null
}
}
async function downloadFileBlob(file: DownloadableFile): Promise<DownloadedFile> {
const response = await fetch(file.href)
if (!response.ok) {
throw new Error(`Failed to download ${file.filename}`)
}
return {
...file,
blob: await response.blob(),
}
}
function dedupeDownloadFiles(files: DownloadableFile[]) {
const result: DownloadableFile[] = []
const hrefs = new Set<string>()
for (const file of files) {
if (hrefs.has(file.href)) continue
hrefs.add(file.href)
result.push(file)
}
return result
}
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), DOWNLOAD_URL_REVOKE_MS)
}
function selectedVersionZipFilename() {
if (!project.value || !selectedVersion.value) return 'download.zip'
return `${sanitizeFilename(project.value.title)} ${sanitizeFilename(
selectedVersion.value.version_number,
)}.zip`
}
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 getDefaultProjectDownloadSelection(): ProjectDownloadSelection {
return {
currentGameVersion: null,
@@ -378,6 +638,10 @@ function getDefaultShowOptions(): ResolvedProjectDownloadModalShowOptions {
}
}
function getStringQueryValue(value: unknown) {
return typeof value === 'string' ? value : null
}
function clearCloseStateResetTimeout() {
if (!closeStateResetTimeout) return
clearTimeout(closeStateResetTimeout)
@@ -400,6 +664,7 @@ async function loadProjectForModal(forceRefetch: boolean) {
function resetDownloadState() {
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
dependencyDownloadFiles.value = []
dependencyDownloadFilesLoaded.value = false
downloadProjectResetKey.value += 1
}
@@ -438,6 +703,7 @@ watch(() => route.hash, openFromHash)
watch(routeProjectId, () => {
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
dependencyDownloadFiles.value = []
dependencyDownloadFilesLoaded.value = false
downloadProjectResetKey.value += 1
})