feat: move download all button in heading with compatible version

This commit is contained in:
tdgao
2026-07-03 13:59:40 -07:00
parent 1bc4922b9f
commit 08109b367a
4 changed files with 248 additions and 187 deletions
@@ -1,25 +1,9 @@
<template>
<div v-if="downloadRows.length > 0" class="flex flex-col gap-1">
<div
v-if="showTitle || downloadableDependencyFiles.length > 0"
class="flex flex-wrap items-center justify-between gap-2"
>
<h3 v-if="showTitle" class="m-0 text-base font-semibold text-contrast">
<div v-if="showTitle" class="flex flex-wrap items-center justify-between gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ sectionTitle }}
</h3>
<ButtonStyled v-if="downloadableDependencyFiles.length > 0" type="transparent">
<button :disabled="downloadingDependencies" @click="downloadAllDependencies">
<SpinnerIcon v-if="downloadingDependencies" aria-hidden="true" class="animate-spin" />
<DownloadIcon v-else aria-hidden="true" />
{{
formatMessage(
downloadingDependencies
? messages.downloadingDependencies
: messages.downloadAllDependencies,
)
}}
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-2">
<DownloadDependency
@@ -34,20 +18,17 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, FileIcon, SpinnerIcon } from '@modrinth/assets'
import { FileIcon } from '@modrinth/assets'
import {
ButtonStyled,
type CdnDownloadReason,
defineMessages,
fileTypeMessages,
injectModrinthClient,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import type { DisplayProjectType } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import JSZip from 'jszip'
import { type Component, computed, ref } from 'vue'
import { type Component, computed, watch } from 'vue'
import DownloadDependency from './DownloadDependency.vue'
@@ -106,12 +87,11 @@ const props = withDefaults(
const emit = defineEmits<{
download: []
'update:downloadable-files': [files: DownloadableDependencyFile[]]
}>()
const client = injectModrinthClient()
const { createProjectDownloadUrl } = useCdnDownloadContext()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const downloadingDependencies = ref(false)
const shouldResolveDependencies = computed(
() => !props.dependencies && !!props.project && !!props.selectedVersion,
@@ -282,18 +262,20 @@ const downloadRows = computed<DownloadDependencyRow[]>(() => [
const sectionTitle = computed(() =>
formatMessage(
dependencyRows.value.length > 0
? messages.dependenciesTitle
: messages.additionalFilesTitle,
dependencyRows.value.length > 0 ? messages.dependenciesTitle : messages.additionalFilesTitle,
),
)
const downloadableDependencyFiles = computed<DownloadableDependencyFile[]>(() =>
collectDownloadableDependencyFiles(downloadRows.value),
collectDownloadableDependencyFiles(dependencyRows.value),
)
const dependencyZipFilename = computed(
() => `${sanitizeFilename(props.project?.title || 'Project')} dependencies.zip`,
watch(
downloadableDependencyFiles,
(files) => {
emit('update:downloadable-files', files)
},
{ immediate: true },
)
function primaryFileForVersion(version?: Labrinth.Versions.v3.Version) {
@@ -396,59 +378,6 @@ function collectDownloadableDependencyFiles(
return files
}
async function downloadAllDependencies() {
if (downloadingDependencies.value || downloadableDependencyFiles.value.length === 0) return
downloadingDependencies.value = true
try {
const zip = new JSZip()
const usedFilenames = new Set<string>()
await Promise.all(
downloadableDependencyFiles.value.map(async (file) => {
const response = await fetch(file.href)
if (!response.ok) {
throw new Error(`Failed to download ${file.name}`)
}
zip.file(uniqueFilename(file.filename, usedFilenames), await response.blob())
}),
)
downloadBlob(
await zip.generateAsync({
type: 'blob',
mimeType: 'application/zip',
}),
dependencyZipFilename.value,
)
emit('download')
} catch (error) {
console.error('Failed to download dependencies:', error)
addNotification({
title: formatMessage(messages.downloadDependenciesFailedTitle),
text: formatMessage(messages.downloadDependenciesFailedText),
type: 'error',
})
} finally {
downloadingDependencies.value = false
}
}
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 filenameFromUrl(url: string) {
try {
const filename = new URL(url).pathname.split('/').pop()
@@ -458,39 +387,6 @@ function filenameFromUrl(url: string) {
}
}
function sanitizeFilename(value: string) {
const sanitized = value
.replace(/[<>:"/\\|?*]/g, '')
.replace(/\s+/g, ' ')
.trim()
return sanitized || 'dependencies'
}
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
}
const messages = defineMessages({
dependenciesTitle: {
id: 'project.download.dependencies-title',
@@ -500,22 +396,6 @@ const messages = defineMessages({
id: 'project.download.additional-files-title',
defaultMessage: 'Additional files',
},
downloadAllDependencies: {
id: 'project.download.dependencies-download-all',
defaultMessage: 'Download all',
},
downloadingDependencies: {
id: 'project.download.dependencies-downloading',
defaultMessage: 'Downloading...',
},
downloadDependenciesFailedTitle: {
id: 'project.download.dependencies-failed-title',
defaultMessage: 'Could not download dependencies',
},
downloadDependenciesFailedText: {
id: 'project.download.dependencies-failed-text',
defaultMessage: 'One or more dependency files could not be downloaded. Please try again.',
},
alreadyInstalledDependency: {
id: 'project.download.dependency-already-installed',
defaultMessage: 'This dependency is already installed',
@@ -80,51 +80,70 @@
</Combobox>
</div>
<div
v-if="selectedVersion"
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, 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"
<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,
)
}}
</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 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"
>
<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"
/>
{{ selectedVersion.name }}
</p>
</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>
<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>
<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>
<p v-else-if="currentPlatform && currentGameVersion && versions.length > 0">
{{
@@ -138,7 +157,7 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, TriangleAlertIcon } from '@modrinth/assets'
import { DownloadIcon, SpinnerIcon, TriangleAlertIcon } from '@modrinth/assets'
import {
ButtonStyled,
type CdnDownloadReason,
@@ -147,6 +166,7 @@ import {
type ComboboxOption,
defineMessages,
getTagMessage,
injectNotificationManager,
truncatedTooltip,
useDebugLogger,
useVIntl,
@@ -154,6 +174,7 @@ import {
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'
defineOptions({
@@ -172,10 +193,16 @@ type ProjectDownloadSelection = {
selectedPrimaryFile: Labrinth.Versions.v3.VersionFile | null
}
type DownloadableFile = {
href: string
filename: string
}
const props = withDefaults(
defineProps<{
project: DownloadModalProject
versions?: Labrinth.Versions.v3.Version[]
dependencyDownloadFiles?: DownloadableFile[]
downloadReason?: CdnDownloadReason
initialGameVersion?: string | null
initialPlatform?: string | null
@@ -185,6 +212,7 @@ const props = withDefaults(
}>(),
{
versions: () => [],
dependencyDownloadFiles: () => [],
downloadReason: 'standalone',
initialGameVersion: null,
initialPlatform: null,
@@ -202,6 +230,7 @@ const emit = defineEmits<{
}>()
const { formatMessage } = useVIntl()
const { createProjectDownloadUrl } = useCdnDownloadContext()
const { addNotification } = injectNotificationManager()
const debug = useDebugLogger('DownloadProject')
const tags = useGeneratedState()
@@ -211,6 +240,7 @@ const showAllVersions = ref(defaultShowAllVersions())
const versionFilter = ref('')
const versionNumberRef = ref<HTMLElement | null>(null)
const versionNameRef = ref<HTMLElement | null>(null)
const downloadingSelectedVersion = ref(false)
const incompatibleGameVersionsSet = computed(() => new Set(props.incompatibleGameVersions))
const incompatibleLoadersSet = computed(() => new Set(props.incompatibleLoaders))
@@ -409,6 +439,36 @@ const selectedPrimaryFileDownloadUrl = computed(() => {
return getDownloadUrl(selectedPrimaryFile.value.url)
})
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)
}
return files
})
const selectedVersionZipFilename = computed(() => {
if (!selectedVersion.value) return `${sanitizeFilename(props.project.title)}.zip`
return `${sanitizeFilename(props.project.title)} ${sanitizeFilename(
selectedVersion.value.version_number,
)}.zip`
})
watch(
[currentGameVersion, currentPlatform, selectedVersion, selectedPrimaryFile],
() => {
@@ -453,6 +513,92 @@ function getDownloadUrl(url: string) {
})
}
async function downloadSelectedVersionFiles() {
if (downloadingSelectedVersion.value || downloadAllFiles.value.length <= 1) return
downloadingSelectedVersion.value = true
try {
const zip = new JSZip()
const usedFilenames = new Set<string>()
await Promise.all(
downloadAllFiles.value.map(async (file) => {
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
}
}
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 loaderLabel(loader: string) {
return formatMessage(getTagMessage(loader, 'loader') ?? messages.unknownLoader)
}
@@ -545,6 +691,26 @@ const messages = defineMessages({
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...',
},
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.',
},
noGameVersionsFound: {
id: 'project.download.no-game-versions-found',
defaultMessage: 'No game versions found',
@@ -16,6 +16,7 @@
<DownloadProject
:project="project"
:versions="versions"
:dependency-download-files="dependencyDownloadFiles"
:download-reason="downloadReason"
:initial-game-version="initialGameVersion"
:initial-platform="initialPlatform"
@@ -35,6 +36,7 @@
:current-platform="currentPlatform"
:download-reason="downloadReason"
:additional-files="additionalFiles"
@update:downloadable-files="dependencyDownloadFiles = $event"
@download="onDownload"
/>
</div>
@@ -90,6 +92,11 @@ type ProjectDownloadSelection = {
selectedPrimaryFile: Labrinth.Versions.v3.VersionFile | null
}
type DownloadableFile = {
href: string
filename: string
}
type NewModalRef = {
show: (event?: MouseEvent) => void
hide: () => void
@@ -139,6 +146,7 @@ const showProjectId = ref<string | null>(null)
const showOptions = ref<ResolvedProjectDownloadModalShowOptions>(getDefaultShowOptions())
const downloadProjectResetKey = ref(0)
const projectDownloadSelection = ref<ProjectDownloadSelection>(getDefaultProjectDownloadSelection())
const dependencyDownloadFiles = ref<DownloadableFile[]>([])
const MODAL_CLOSE_STATE_RESET_MS = 350
let closeStateResetTimeout: ReturnType<typeof setTimeout> | null = null
@@ -416,6 +424,7 @@ watch(modal, openFromHash)
watch(() => route.hash, openFromHash)
watch(routeProjectId, () => {
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
dependencyDownloadFiles.value = []
downloadProjectResetKey.value += 1
})
+17 -11
View File
@@ -3242,17 +3242,8 @@
"project.download.base-loader-incompatible-tooltip": {
"message": "This loader is incompatible with the base project."
},
"project.download.dependencies-download-all": {
"message": "Download all"
},
"project.download.dependencies-downloading": {
"message": "Downloading..."
},
"project.download.dependencies-failed-text": {
"message": "One or more dependency files could not be downloaded. Please try again."
},
"project.download.dependencies-failed-title": {
"message": "Could not download dependencies"
"project.download.compatible-version-title": {
"message": "Compatible version"
},
"project.download.dependencies-title": {
"message": "Dependencies"
@@ -3281,6 +3272,9 @@
"project.download.download-version": {
"message": "Download {version}"
},
"project.download.file-unavailable": {
"message": "This file cannot be downloaded"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} does not support {gameVersion} for {platform}"
},
@@ -3311,6 +3305,18 @@
"project.download.select-platform": {
"message": "Select platform"
},
"project.download.selected-version-download-all": {
"message": "Download all (.zip)"
},
"project.download.selected-version-downloading": {
"message": "Downloading..."
},
"project.download.selected-version-failed-text": {
"message": "One or more version files could not be downloaded. Please try again."
},
"project.download.selected-version-failed-title": {
"message": "Could not download version"
},
"project.download.show-all-versions": {
"message": "Show all versions"
},