Files
modrinth/apps/frontend/src/components/ui/ProjectDownloadModal/index.vue
T

737 lines
20 KiB
Vue

<template>
<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" />
<div
ref="downloadTitleRef"
v-tooltip="truncatedTooltip(downloadTitleRef, downloadTitle)"
class="truncate text-lg font-extrabold text-contrast"
>
{{ downloadTitle }}
</div>
</template>
</template>
<template #default>
<div v-if="project" class="mx-auto flex w-full flex-col gap-4">
<InstallWithModrinthApp :project="project" />
<DownloadProject
:project="project"
:versions="versions"
:dependency-download-files="dependencyDownloadFiles"
:download-reason="downloadReason"
:initial-game-version="initialGameVersion"
:initial-platform="initialPlatform"
:incompatible-game-versions="showOptions.incompatibleGameVersions"
:incompatible-loaders="showOptions.incompatibleLoaders"
:reset-key="downloadProjectResetKey"
@select-game-version="selectGameVersion"
@select-platform="selectPlatform"
@update:selection="updateProjectDownloadSelection"
@download="onDownload"
/>
<div class="flex flex-col gap-4">
<DownloadDependencies
:project="project"
:selected-version="selectedVersion"
:current-game-version="currentGameVersion"
:current-platform="currentPlatform"
:download-reason="downloadReason"
:additional-files="additionalFiles"
@update:downloadable-files="dependencyDownloadFiles = $event"
@update:downloadable-files-loaded="dependencyDownloadFilesLoaded = $event"
@download="onDownload"
/>
</div>
<ServersPromo
v-if="flags.showProjectPageDownloadModalServersPromo"
:link="`/hosting#plan`"
@close="
() => {
flags.showProjectPageDownloadModalServersPromo = false
saveFeatureFlags()
}
"
/>
</div>
</template>
<template v-if="showDependencyDownloadActions" #actions>
<div class="flex flex-wrap justify-end gap-2">
<ButtonStyled>
<button
class="!shadow-none"
: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
class="!shadow-none"
: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,
useDebugLogger,
useVIntl,
} 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'
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
import DownloadDependencies from './DownloadDependencies.vue'
import DownloadProject from './DownloadProject.vue'
import InstallWithModrinthApp from './InstallWithModrinthApp.vue'
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
project_type: DisplayProjectType
actualProjectType: Labrinth.Projects.v2.ProjectType
}
type ProjectDownloadSelection = {
currentGameVersion: string | null
currentPlatform: string | null
selectedVersion: Labrinth.Versions.v3.Version | null
selectedPrimaryFile: Labrinth.Versions.v3.VersionFile | null
}
type DownloadableFile = {
href: string
filename: string
}
type DownloadedFile = DownloadableFile & {
blob: Blob
}
type DownloadActionType = 'zip' | 'dependencies'
type NewModalRef = {
show: (event?: MouseEvent) => void
hide: () => void
}
type ProjectDownloadModalShowOptions = {
projectId?: string
incompatibleGameVersions?: string[]
incompatibleLoaders?: string[]
}
type ResolvedProjectDownloadModalShowOptions = {
projectId?: string
incompatibleGameVersions: string[]
incompatibleLoaders: string[]
}
const props = withDefaults(
defineProps<{
projectId?: string
downloadReason?: CdnDownloadReason
useRouteHash?: boolean
updateRouteSelection?: boolean
}>(),
{
downloadReason: 'standalone',
useRouteHash: true,
updateRouteSelection: true,
},
)
const emit = defineEmits<{
download: []
}>()
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')
const modal = ref<NewModalRef | null>(null)
const downloadTitleRef = ref<HTMLElement | null>(null)
const modalOpen = ref(false)
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
const DOWNLOAD_STAGGER_MS = 500
let closeStateResetTimeout: ReturnType<typeof setTimeout> | null = null
const routeProjectId = computed(() => showProjectId.value ?? props.projectId ?? null)
const {
data: projectRaw,
error: projectV2Error,
refetch: refetchProject,
} = useQuery({
queryKey: computed(() => ['project', 'v2', routeProjectId.value]),
queryFn: () => client.labrinth.projects_v2.get(routeProjectId.value!),
enabled: computed(() => !!routeProjectId.value),
staleTime: STALE_TIME,
})
const resolvedProjectId = computed(() => projectRaw.value?.id)
const project = computed<DownloadModalProject | null>(() => {
if (!projectRaw.value) return null
return {
...projectRaw.value,
actualProjectType: projectRaw.value.project_type,
project_type: getProjectTypeForUrl(projectRaw.value.project_type, projectRaw.value.loaders),
}
})
const downloadTitle = computed(() => {
if (!project.value) return ''
return formatMessage(messages.downloadTitle, { title: project.value.title })
})
const versionsEnabled = ref(false)
const { data: versionsV3, isFetching: versionsV3Loading } = useQuery({
queryKey: computed(() => ['project', resolvedProjectId.value, 'versions', 'v3']),
queryFn: () =>
client.labrinth.versions_v3.getProjectVersions(resolvedProjectId.value!, {
include_changelog: false,
apiVersion: 3,
}),
staleTime: STALE_TIME_LONG,
enabled: computed(() => !!resolvedProjectId.value && versionsEnabled.value),
})
const versions = computed<Labrinth.Versions.v3.Version[]>(() => {
const isModpack =
project.value?.actualProjectType === 'modpack' || project.value?.project_type === 'modpack'
return (versionsV3.value ?? []).map((version) => {
const files = Array.isArray(version.files) ? version.files : []
const gameVersions = Array.isArray(version.game_versions) ? version.game_versions : []
const loaders = Array.isArray(version.loaders) ? version.loaders : []
const mrpackLoaders = Array.isArray(version.mrpack_loaders) ? version.mrpack_loaders : []
return {
...version,
files,
game_versions: gameVersions,
loaders: isModpack && mrpackLoaders.length ? mrpackLoaders : loaders,
}
})
})
const initialGameVersion = computed(() => {
const version = route.query.version
if (typeof version !== 'string' || !project.value?.game_versions.includes(version)) return null
return version
})
const initialPlatform = computed(() => {
const loader = route.query.loader
if (typeof loader !== 'string' || !project.value?.loaders.includes(loader)) return null
return loader
})
const currentGameVersion = computed(() => projectDownloadSelection.value.currentGameVersion)
const currentPlatform = computed(() => projectDownloadSelection.value.currentPlatform)
const selectedVersion = computed(() => projectDownloadSelection.value.selectedVersion)
const selectedPrimaryFile = computed(() => projectDownloadSelection.value.selectedPrimaryFile)
const additionalFiles = computed(() => {
if (!selectedVersion.value || !selectedPrimaryFile.value) return []
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)
}
})
const messages = defineMessages({
downloadTitle: {
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(
type: Labrinth.Projects.v2.ProjectType,
loaders: string[],
): DisplayProjectType {
if (type !== 'mod') return type as DisplayProjectType
const isMod = loaders.some((loader) => tags.value.loaderData.modLoaders.includes(loader))
const isPlugin = loaders.some((loader) => tags.value.loaderData.allPluginLoaders.includes(loader))
const isDataPack = loaders.some((loader) =>
tags.value.loaderData.dataPackLoaders.includes(loader),
)
if (isDataPack) return 'datapack'
if (isPlugin) return 'plugin'
if (isMod) return 'mod'
return 'mod'
}
function updateDownloadQuery({
gameVersion,
platform,
}: {
gameVersion: string | null
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,
...(nextGameVersion && {
version: nextGameVersion,
}),
...(nextPlatform && {
loader: nextPlatform,
}),
},
hash: route.hash,
},
{ replace: true },
)
}
function selectGameVersion(gameVersion: string) {
updateDownloadQuery({
gameVersion,
platform: null,
})
}
function selectPlatform(platform: string) {
updateDownloadQuery({
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
debug('on-show fired')
versionsEnabled.value = true
if (props.useRouteHash && !showProjectId.value) {
navigateTo({ query: route.query, hash: '#download' }, { replace: true })
}
}
function onHide() {
const hadShowProjectId = !!showProjectId.value
modalOpen.value = false
clearCloseStateResetTimeout()
closeStateResetTimeout = setTimeout(() => {
showProjectId.value = null
showOptions.value = getDefaultShowOptions()
closeStateResetTimeout = null
}, MODAL_CLOSE_STATE_RESET_MS)
if (props.useRouteHash && !hadShowProjectId) {
navigateTo({ query: route.query, hash: '' }, { replace: true })
}
}
async function show(
event?: MouseEvent,
options: ProjectDownloadModalShowOptions = {},
): Promise<void> {
if (!modal.value || modalOpen.value) return
await waitForCloseStateReset()
if (!modal.value || modalOpen.value) return
showOptions.value = {
...getDefaultShowOptions(),
...options,
}
showProjectId.value = showOptions.value.projectId ?? null
await nextTick()
if (!(await loadProjectForModal(!!showOptions.value.projectId))) return
resetDownloadState()
modalOpen.value = true
modal.value.show(event)
}
function hide() {
if (!modal.value || !modalOpen.value) return
modal.value?.hide()
}
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>()
const downloadedFiles = await downloadFileBlobs(files)
for (const file of downloadedFiles) {
zip.file(uniqueFilename(file.filename, usedFilenames), file.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,
])
await downloadFiles(files)
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 downloadFileBlobs(files: DownloadableFile[]): Promise<DownloadedFile[]> {
return Promise.all(files.map((file) => downloadFileBlob(file)))
}
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(),
}
}
async function downloadFiles(files: DownloadableFile[]) {
await Promise.all(
files.map(async (file, index) => {
await delay(DOWNLOAD_STAGGER_MS * index)
downloadFileLink(file)
}),
)
}
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 downloadFileLink(file: DownloadableFile) {
const link = document.createElement('a')
link.href = file.href
link.download = file.filename
document.body.appendChild(link)
link.click()
link.remove()
}
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,
currentPlatform: null,
selectedVersion: null,
selectedPrimaryFile: null,
}
}
function getDefaultShowOptions(): ResolvedProjectDownloadModalShowOptions {
return {
projectId: undefined,
incompatibleGameVersions: [],
incompatibleLoaders: [],
}
}
function getStringQueryValue(value: unknown) {
return typeof value === 'string' ? value : null
}
function clearCloseStateResetTimeout() {
if (!closeStateResetTimeout) return
clearTimeout(closeStateResetTimeout)
closeStateResetTimeout = null
}
async function waitForCloseStateReset() {
if (!closeStateResetTimeout) return
await delay(MODAL_CLOSE_STATE_RESET_MS)
}
async function delay(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms))
}
async function loadProjectForModal(forceRefetch: boolean) {
if (!routeProjectId.value) return false
if (!forceRefetch && projectRaw.value) return true
const { data } = await refetchProject()
return !!data
}
function resetDownloadState() {
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
dependencyDownloadFiles.value = []
dependencyDownloadFilesLoaded.value = false
downloadProjectResetKey.value += 1
}
function openFromHash() {
if (
!props.useRouteHash ||
!modal.value ||
modalOpen.value ||
showProjectId.value ||
route.hash !== '#download'
) {
return
}
debug('hash #download watch fired, opening modal')
show()
}
if (
props.useRouteHash &&
(route.hash === '#download' ||
route.query.version !== undefined ||
route.query.loader !== undefined)
) {
debug('eager loadVersions from setup', {
hash: route.hash,
version: route.query.version,
loader: route.query.loader,
loading: versionsV3Loading.value,
})
versionsEnabled.value = true
}
watch(modal, openFromHash)
watch(() => route.hash, openFromHash)
watch(routeProjectId, () => {
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
dependencyDownloadFiles.value = []
dependencyDownloadFilesLoaded.value = false
downloadProjectResetKey.value += 1
})
onUnmounted(clearCloseStateResetTimeout)
defineExpose({ show, hide })
</script>