mirror of
https://github.com/modrinth/code.git
synced 2026-09-04 05:48:57 +00:00
feat: implement compatible version card and update download dependency buttons
This commit is contained in:
@@ -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<{
|
const emit = defineEmits<{
|
||||||
download: []
|
download: []
|
||||||
'update:downloadable-files': [files: DownloadableDependencyFile[]]
|
'update:downloadable-files': [files: DownloadableDependencyFile[]]
|
||||||
|
'update:downloadable-files-loaded': [loaded: boolean]
|
||||||
}>()
|
}>()
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
||||||
@@ -109,7 +110,7 @@ const dependencyResolutionPreferences = computed<Labrinth.Content.v3.ResolutionP
|
|||||||
loaders: props.currentPlatform ? [props.currentPlatform] : props.selectedVersion?.loaders || [],
|
loaders: props.currentPlatform ? [props.currentPlatform] : props.selectedVersion?.loaders || [],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const { data: dependencyResolution } = useQuery({
|
const { data: dependencyResolution, isFetching: dependencyResolutionFetching } = useQuery({
|
||||||
queryKey: computed(() => [
|
queryKey: computed(() => [
|
||||||
'project-download-modal',
|
'project-download-modal',
|
||||||
'content-resolve',
|
'content-resolve',
|
||||||
@@ -147,7 +148,7 @@ const dependencyVersionIds = computed<string[]>(() => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: dependencyVersions } = useQuery({
|
const { data: dependencyVersions, isFetching: dependencyVersionsFetching } = useQuery({
|
||||||
queryKey: computed(() => [
|
queryKey: computed(() => [
|
||||||
'project-download-modal',
|
'project-download-modal',
|
||||||
'resolved-versions',
|
'resolved-versions',
|
||||||
@@ -176,7 +177,7 @@ const dependencyProjectIds = computed<string[]>(() => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: dependencyProjects } = useQuery({
|
const { data: dependencyProjects, isFetching: dependencyProjectsFetching } = useQuery({
|
||||||
queryKey: computed(() => [
|
queryKey: computed(() => [
|
||||||
'project-download-modal',
|
'project-download-modal',
|
||||||
'resolved-projects',
|
'resolved-projects',
|
||||||
@@ -210,6 +211,7 @@ const dependenciesByParentVersionId = computed(() => {
|
|||||||
|
|
||||||
const dependenciesLoaded = computed(() => {
|
const dependenciesLoaded = computed(() => {
|
||||||
if (!shouldResolveDependencies.value) return false
|
if (!shouldResolveDependencies.value) return false
|
||||||
|
if (dependencyResolutionFetching.value) return false
|
||||||
if (!dependencyResolution.value) return false
|
if (!dependencyResolution.value) return false
|
||||||
if (
|
if (
|
||||||
dependencyResolution.value.primary.version_id &&
|
dependencyResolution.value.primary.version_id &&
|
||||||
@@ -218,11 +220,13 @@ const dependenciesLoaded = computed(() => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
dependencyVersionsFetching.value ||
|
||||||
!dependencyVersionIds.value.every((versionId) => dependencyVersionById.value.has(versionId))
|
!dependencyVersionIds.value.every((versionId) => dependencyVersionById.value.has(versionId))
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
dependencyProjectsFetching.value ||
|
||||||
!dependencyProjectIds.value.every((projectId) => dependencyProjectById.value.has(projectId))
|
!dependencyProjectIds.value.every((projectId) => dependencyProjectById.value.has(projectId))
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
@@ -288,6 +292,12 @@ const downloadableDependencyFiles = computed<DownloadableDependencyFile[]>(() =>
|
|||||||
collectDownloadableDependencyFiles(visibleDependencyRows.value),
|
collectDownloadableDependencyFiles(visibleDependencyRows.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const downloadableDependencyFilesLoaded = computed(() => {
|
||||||
|
if (props.dependencies) return true
|
||||||
|
if (!shouldResolveDependencies.value) return false
|
||||||
|
return dependenciesLoaded.value
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
downloadableDependencyFiles,
|
downloadableDependencyFiles,
|
||||||
(files) => {
|
(files) => {
|
||||||
@@ -296,6 +306,14 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
downloadableDependencyFilesLoaded,
|
||||||
|
(loaded) => {
|
||||||
|
emit('update:downloadable-files-loaded', loaded)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
function primaryFileForVersion(version?: Labrinth.Versions.v3.Version) {
|
function primaryFileForVersion(version?: Labrinth.Versions.v3.Version) {
|
||||||
return version?.files?.find((file) => file.primary) || version?.files?.[0]
|
return version?.files?.find((file) => file.primary) || version?.files?.[0]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,107 +80,59 @@
|
|||||||
</Combobox>
|
</Combobox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedVersion" class="flex flex-col gap-1">
|
<div v-if="selectedVersion" class="flex flex-col gap-2.5">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<h3
|
||||||
<h3 class="relative top-0.5 m-0 text-base font-semibold text-contrast">
|
v-if="[...suggestedPreReleaseVersions, selectedVersion].length > 1"
|
||||||
{{ formatMessage(messages.compatibleVersionTitle) }}
|
class="relative top-0.5 m-0 text-base font-semibold text-contrast"
|
||||||
</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 class="flex min-w-0 flex-col gap-1">
|
{{ formatMessage(messages.compatibleVersionTitle) }}
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
</h3>
|
||||||
<nuxt-link
|
<CompatibleVersionCard
|
||||||
v-tooltip="truncatedTooltip(versionNumberRef, selectedVersion.version_number)"
|
:project="project"
|
||||||
:to="`/${project.project_type}/${project.slug || project.id}/version/${selectedVersion.id}`"
|
:version="selectedVersion"
|
||||||
target="_blank"
|
:download-reason="downloadReason"
|
||||||
rel="noopener noreferrer"
|
:current-game-version="currentGameVersion"
|
||||||
class="block min-w-0 text-contrast no-underline hover:underline"
|
:current-platform="currentPlatform"
|
||||||
>
|
:color="hasAdditionalDownloads ? 'standard' : 'brand'"
|
||||||
<span ref="versionNumberRef" class="block truncate font-semibold">
|
:type="hasAdditionalDownloads ? 'transparent' : 'standard'"
|
||||||
{{ selectedVersion.version_number }}
|
:circular="hasAdditionalDownloads"
|
||||||
</span>
|
@download="emit('download')"
|
||||||
</nuxt-link>
|
/>
|
||||||
<VersionChannelTag
|
<CompatibleVersionCard
|
||||||
:channel="selectedVersion.version_type"
|
v-for="suggestedVersion in suggestedPreReleaseVersions"
|
||||||
class="relative -top-px !py-0.5"
|
:key="suggestedVersion.version.id"
|
||||||
/>
|
:project="project"
|
||||||
</div>
|
:version="suggestedVersion.version"
|
||||||
<p
|
:download-reason="downloadReason"
|
||||||
ref="versionNameRef"
|
:current-game-version="currentGameVersion"
|
||||||
v-tooltip="truncatedTooltip(versionNameRef, selectedVersion.name)"
|
:current-platform="currentPlatform"
|
||||||
class="m-0 w-fit max-w-full truncate text-sm text-secondary"
|
color="standard"
|
||||||
>
|
type="transparent"
|
||||||
{{ selectedVersion.name }}
|
circular
|
||||||
</p>
|
@download="emit('download')"
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-else-if="currentPlatform && currentGameVersion && versions.length > 0">
|
|
||||||
{{
|
|
||||||
formatMessage(messages.noVersionsAvailable, {
|
|
||||||
gameVersion: currentGameVersion,
|
|
||||||
platform: currentPlatformText,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Labrinth } from '@modrinth/api-client'
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import { DownloadIcon, SpinnerIcon, TriangleAlertIcon } from '@modrinth/assets'
|
import { TriangleAlertIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
ButtonStyled,
|
|
||||||
type CdnDownloadReason,
|
type CdnDownloadReason,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Combobox,
|
Combobox,
|
||||||
type ComboboxOption,
|
type ComboboxOption,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
getTagMessage,
|
getTagMessage,
|
||||||
injectNotificationManager,
|
|
||||||
truncatedTooltip,
|
|
||||||
useDebugLogger,
|
useDebugLogger,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import VersionChannelTag from '@modrinth/ui/src/components/version/VersionChannelTag.vue'
|
|
||||||
import type { DisplayProjectType } from '@modrinth/utils'
|
import type { DisplayProjectType } from '@modrinth/utils'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import JSZip from 'jszip'
|
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import CompatibleVersionCard from './CompatibleVersionCard.vue'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'DownloadProject',
|
name: 'DownloadProject',
|
||||||
})
|
})
|
||||||
@@ -202,6 +154,10 @@ type DownloadableFile = {
|
|||||||
filename: string
|
filename: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SuggestedPreReleaseVersion = {
|
||||||
|
version: Labrinth.Versions.v3.Version
|
||||||
|
}
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
project: DownloadModalProject
|
project: DownloadModalProject
|
||||||
@@ -233,8 +189,6 @@ const emit = defineEmits<{
|
|||||||
'update:selection': [selection: ProjectDownloadSelection]
|
'update:selection': [selection: ProjectDownloadSelection]
|
||||||
}>()
|
}>()
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
|
||||||
const { addNotification } = injectNotificationManager()
|
|
||||||
const debug = useDebugLogger('DownloadProject')
|
const debug = useDebugLogger('DownloadProject')
|
||||||
const tags = useGeneratedState()
|
const tags = useGeneratedState()
|
||||||
|
|
||||||
@@ -242,13 +196,6 @@ const userSelectedGameVersion = ref<string | null>(props.initialGameVersion)
|
|||||||
const userSelectedPlatform = ref<string | null>(props.initialPlatform)
|
const userSelectedPlatform = ref<string | null>(props.initialPlatform)
|
||||||
const showAllVersions = ref(defaultShowAllVersions())
|
const showAllVersions = ref(defaultShowAllVersions())
|
||||||
const versionFilter = ref('')
|
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 incompatibleGameVersionsSet = computed(() => new Set(props.incompatibleGameVersions))
|
||||||
const incompatibleLoadersSet = computed(() => new Set(props.incompatibleLoaders))
|
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>(() => {
|
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>(() => {
|
const filteredBeta = computed<Labrinth.Versions.v3.Version | undefined>(() => {
|
||||||
return filteredVersions.value.find(
|
return latestVersionByType('beta')
|
||||||
(x) =>
|
|
||||||
x.version_type === 'beta' &&
|
|
||||||
(!filteredRelease.value ||
|
|
||||||
dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredAlpha = computed<Labrinth.Versions.v3.Version | undefined>(() => {
|
const filteredAlpha = computed<Labrinth.Versions.v3.Version | undefined>(() => {
|
||||||
return filteredVersions.value.find(
|
return latestVersionByType('alpha')
|
||||||
(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))),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedVersion = computed<Labrinth.Versions.v3.Version | null>(() => {
|
const selectedVersion = computed<Labrinth.Versions.v3.Version | null>(() => {
|
||||||
@@ -442,39 +377,40 @@ const selectedPrimaryFile = computed<Labrinth.Versions.v3.VersionFile | null>(()
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedPrimaryFileDownloadUrl = computed(() => {
|
const suggestedPreReleaseVersions = computed<SuggestedPreReleaseVersion[]>(() => {
|
||||||
if (!selectedPrimaryFile.value) return '#'
|
if (!selectedVersion.value || selectedVersion.value.version_type !== 'release') return []
|
||||||
return getDownloadUrl(selectedPrimaryFile.value.url)
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedVersionDownloadFiles = computed(() => {
|
const versions: SuggestedPreReleaseVersion[] = []
|
||||||
if (!selectedVersion.value) return []
|
const beta = filteredBeta.value
|
||||||
|
if (beta && isNewerThan(beta, selectedVersion.value)) {
|
||||||
return selectedVersion.value.files.map((file) => ({
|
versions.push({
|
||||||
href: getDownloadUrl(file.url),
|
version: beta,
|
||||||
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 alpha = filteredAlpha.value
|
||||||
|
if (alpha && isNewerThan(alpha, selectedVersion.value)) {
|
||||||
|
versions.push({
|
||||||
|
version: alpha,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return versions
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedVersionZipFilename = computed(() => {
|
const hasAdditionalDownloads = computed(() => {
|
||||||
if (!selectedVersion.value) return `${sanitizeFilename(props.project.title)}.zip`
|
const hrefs = new Set<string>()
|
||||||
|
|
||||||
return `${sanitizeFilename(props.project.title)} ${sanitizeFilename(
|
for (const file of selectedVersion.value?.files ?? []) {
|
||||||
selectedVersion.value.version_number,
|
hrefs.add(file.url)
|
||||||
)}.zip`
|
}
|
||||||
|
|
||||||
|
for (const file of props.dependencyDownloadFiles) {
|
||||||
|
if (hrefs.has(file.href)) continue
|
||||||
|
hrefs.add(file.href)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hrefs.size > 1
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -513,109 +449,20 @@ function selectPlatform(platform?: string) {
|
|||||||
emit('selectPlatform', platform)
|
emit('selectPlatform', platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDownloadUrl(url: string) {
|
function latestVersionByType(type: Labrinth.Versions.v3.VersionChannel) {
|
||||||
return createProjectDownloadUrl(url, {
|
return filteredVersions.value
|
||||||
reason: props.downloadReason,
|
.filter((version) => version.version_type === type)
|
||||||
gameVersion: currentGameVersion.value ?? undefined,
|
.reduce<Labrinth.Versions.v3.Version | undefined>((latest, version) => {
|
||||||
loader: currentPlatform.value ?? undefined,
|
if (!latest || isNewerThan(version, latest)) return version
|
||||||
})
|
return latest
|
||||||
|
}, undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadSelectedVersionFiles() {
|
function isNewerThan(
|
||||||
if (downloadingSelectedVersion.value || downloadAllFiles.value.length <= 1) return
|
version: Labrinth.Versions.v3.Version,
|
||||||
|
comparison: Labrinth.Versions.v3.Version,
|
||||||
downloadingSelectedVersion.value = true
|
) {
|
||||||
const files = [...downloadAllFiles.value]
|
return dayjs(version.date_published).isAfter(dayjs(comparison.date_published))
|
||||||
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 loaderLabel(loader: string) {
|
function loaderLabel(loader: string) {
|
||||||
@@ -706,38 +553,14 @@ const messages = defineMessages({
|
|||||||
id: 'project.download.game-version-unsupported-tooltip',
|
id: 'project.download.game-version-unsupported-tooltip',
|
||||||
defaultMessage: '{title} does not support {gameVersion} for {platform}',
|
defaultMessage: '{title} does not support {gameVersion} for {platform}',
|
||||||
},
|
},
|
||||||
downloadVersion: {
|
|
||||||
id: 'project.download.download-version',
|
|
||||||
defaultMessage: 'Download {version}',
|
|
||||||
},
|
|
||||||
compatibleVersionTitle: {
|
compatibleVersionTitle: {
|
||||||
id: 'project.download.compatible-version-title',
|
id: 'project.download.compatible-version-title',
|
||||||
defaultMessage: 'Compatible version',
|
defaultMessage: 'Compatible versions',
|
||||||
},
|
|
||||||
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.',
|
|
||||||
},
|
},
|
||||||
noGameVersionsFound: {
|
noGameVersionsFound: {
|
||||||
id: 'project.download.no-game-versions-found',
|
id: 'project.download.no-game-versions-found',
|
||||||
defaultMessage: '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: {
|
platformUnsupportedTooltip: {
|
||||||
id: 'project.download.platform-unsupported-tooltip',
|
id: 'project.download.platform-unsupported-tooltip',
|
||||||
defaultMessage: '{title} does not support {platform} for {gameVersion}',
|
defaultMessage: '{title} does not support {platform} for {gameVersion}',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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 #title>
|
||||||
<template v-if="project">
|
<template v-if="project">
|
||||||
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
|
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
:reset-key="downloadProjectResetKey"
|
:reset-key="downloadProjectResetKey"
|
||||||
@select-game-version="selectGameVersion"
|
@select-game-version="selectGameVersion"
|
||||||
@select-platform="selectPlatform"
|
@select-platform="selectPlatform"
|
||||||
@update:selection="projectDownloadSelection = $event"
|
@update:selection="updateProjectDownloadSelection"
|
||||||
@download="onDownload"
|
@download="onDownload"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
:download-reason="downloadReason"
|
:download-reason="downloadReason"
|
||||||
:additional-files="additionalFiles"
|
:additional-files="additionalFiles"
|
||||||
@update:downloadable-files="dependencyDownloadFiles = $event"
|
@update:downloadable-files="dependencyDownloadFiles = $event"
|
||||||
|
@update:downloadable-files-loaded="dependencyDownloadFilesLoaded = $event"
|
||||||
@download="onDownload"
|
@download="onDownload"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,16 +55,48 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
</NewModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Labrinth } from '@modrinth/api-client'
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
|
import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
ButtonStyled,
|
||||||
type CdnDownloadReason,
|
type CdnDownloadReason,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
|
injectNotificationManager,
|
||||||
NewModal,
|
NewModal,
|
||||||
ServersPromo,
|
ServersPromo,
|
||||||
truncatedTooltip,
|
truncatedTooltip,
|
||||||
@@ -72,6 +105,7 @@ import {
|
|||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import type { DisplayProjectType } from '@modrinth/utils'
|
import type { DisplayProjectType } from '@modrinth/utils'
|
||||||
import { useQuery } from '@tanstack/vue-query'
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
|
import JSZip from 'jszip'
|
||||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { navigateTo } from '#app'
|
import { navigateTo } from '#app'
|
||||||
@@ -99,6 +133,12 @@ type DownloadableFile = {
|
|||||||
filename: string
|
filename: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DownloadedFile = DownloadableFile & {
|
||||||
|
blob: Blob
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadActionType = 'zip' | 'dependencies'
|
||||||
|
|
||||||
type NewModalRef = {
|
type NewModalRef = {
|
||||||
show: (event?: MouseEvent) => void
|
show: (event?: MouseEvent) => void
|
||||||
hide: () => void
|
hide: () => void
|
||||||
@@ -138,6 +178,8 @@ const route = useRoute()
|
|||||||
const flags = useFeatureFlags()
|
const flags = useFeatureFlags()
|
||||||
const tags = useGeneratedState()
|
const tags = useGeneratedState()
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
|
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
||||||
|
const { addNotification } = injectNotificationManager()
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const debug = useDebugLogger('DownloadModal')
|
const debug = useDebugLogger('DownloadModal')
|
||||||
|
|
||||||
@@ -148,8 +190,15 @@ const showProjectId = ref<string | null>(null)
|
|||||||
const showOptions = ref<ResolvedProjectDownloadModalShowOptions>(getDefaultShowOptions())
|
const showOptions = ref<ResolvedProjectDownloadModalShowOptions>(getDefaultShowOptions())
|
||||||
const downloadProjectResetKey = ref(0)
|
const downloadProjectResetKey = ref(0)
|
||||||
const projectDownloadSelection = ref<ProjectDownloadSelection>(getDefaultProjectDownloadSelection())
|
const projectDownloadSelection = ref<ProjectDownloadSelection>(getDefaultProjectDownloadSelection())
|
||||||
|
const pendingRouteSelection = ref({
|
||||||
|
gameVersion: getStringQueryValue(route.query.version),
|
||||||
|
platform: getStringQueryValue(route.query.loader),
|
||||||
|
})
|
||||||
const dependencyDownloadFiles = ref<DownloadableFile[]>([])
|
const dependencyDownloadFiles = ref<DownloadableFile[]>([])
|
||||||
|
const dependencyDownloadFilesLoaded = ref(false)
|
||||||
|
const downloadingActionType = ref<DownloadActionType | null>(null)
|
||||||
const MODAL_CLOSE_STATE_RESET_MS = 350
|
const MODAL_CLOSE_STATE_RESET_MS = 350
|
||||||
|
const DOWNLOAD_URL_REVOKE_MS = 60000
|
||||||
let closeStateResetTimeout: ReturnType<typeof setTimeout> | null = null
|
let closeStateResetTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const routeProjectId = computed(() => showProjectId.value ?? props.projectId ?? null)
|
const routeProjectId = computed(() => showProjectId.value ?? props.projectId ?? null)
|
||||||
@@ -183,11 +232,7 @@ const downloadTitle = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const versionsEnabled = ref(false)
|
const versionsEnabled = ref(false)
|
||||||
const {
|
const { data: versionsV3, isFetching: versionsV3Loading } = useQuery({
|
||||||
data: versionsV3,
|
|
||||||
error: _versionsV3Error,
|
|
||||||
isFetching: versionsV3Loading,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: computed(() => ['project', resolvedProjectId.value, 'versions', 'v3']),
|
queryKey: computed(() => ['project', resolvedProjectId.value, 'versions', 'v3']),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
client.labrinth.versions_v3.getProjectVersions(resolvedProjectId.value!, {
|
client.labrinth.versions_v3.getProjectVersions(resolvedProjectId.value!, {
|
||||||
@@ -238,6 +283,23 @@ const additionalFiles = computed(() => {
|
|||||||
return selectedVersion.value.files.filter((file) => file !== selectedPrimaryFile.value)
|
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) => {
|
watch(projectV2Error, (error) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
debug('project query failed', error)
|
debug('project query failed', error)
|
||||||
@@ -249,6 +311,22 @@ const messages = defineMessages({
|
|||||||
id: 'project.download.title',
|
id: 'project.download.title',
|
||||||
defaultMessage: '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(
|
function getProjectTypeForUrl(
|
||||||
@@ -278,15 +356,27 @@ function updateDownloadQuery({
|
|||||||
platform: string | null
|
platform: string | null
|
||||||
}) {
|
}) {
|
||||||
if (!props.updateRouteSelection) return
|
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(
|
navigateTo(
|
||||||
{
|
{
|
||||||
query: {
|
query: {
|
||||||
...route.query,
|
...route.query,
|
||||||
...(gameVersion && {
|
...(nextGameVersion && {
|
||||||
version: gameVersion,
|
version: nextGameVersion,
|
||||||
}),
|
}),
|
||||||
...(platform && {
|
...(nextPlatform && {
|
||||||
loader: platform,
|
loader: nextPlatform,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
hash: route.hash,
|
hash: route.hash,
|
||||||
@@ -298,17 +388,35 @@ function updateDownloadQuery({
|
|||||||
function selectGameVersion(gameVersion: string) {
|
function selectGameVersion(gameVersion: string) {
|
||||||
updateDownloadQuery({
|
updateDownloadQuery({
|
||||||
gameVersion,
|
gameVersion,
|
||||||
platform: currentPlatform.value,
|
platform: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPlatform(platform: string) {
|
function selectPlatform(platform: string) {
|
||||||
updateDownloadQuery({
|
updateDownloadQuery({
|
||||||
gameVersion: currentGameVersion.value,
|
gameVersion: null,
|
||||||
platform,
|
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() {
|
function onShow() {
|
||||||
clearCloseStateResetTimeout()
|
clearCloseStateResetTimeout()
|
||||||
modalOpen.value = true
|
modalOpen.value = true
|
||||||
@@ -361,6 +469,158 @@ function onDownload() {
|
|||||||
emit('download')
|
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 {
|
function getDefaultProjectDownloadSelection(): ProjectDownloadSelection {
|
||||||
return {
|
return {
|
||||||
currentGameVersion: null,
|
currentGameVersion: null,
|
||||||
@@ -378,6 +638,10 @@ function getDefaultShowOptions(): ResolvedProjectDownloadModalShowOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStringQueryValue(value: unknown) {
|
||||||
|
return typeof value === 'string' ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
function clearCloseStateResetTimeout() {
|
function clearCloseStateResetTimeout() {
|
||||||
if (!closeStateResetTimeout) return
|
if (!closeStateResetTimeout) return
|
||||||
clearTimeout(closeStateResetTimeout)
|
clearTimeout(closeStateResetTimeout)
|
||||||
@@ -400,6 +664,7 @@ async function loadProjectForModal(forceRefetch: boolean) {
|
|||||||
function resetDownloadState() {
|
function resetDownloadState() {
|
||||||
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
|
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
|
||||||
dependencyDownloadFiles.value = []
|
dependencyDownloadFiles.value = []
|
||||||
|
dependencyDownloadFilesLoaded.value = false
|
||||||
downloadProjectResetKey.value += 1
|
downloadProjectResetKey.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,6 +703,7 @@ watch(() => route.hash, openFromHash)
|
|||||||
watch(routeProjectId, () => {
|
watch(routeProjectId, () => {
|
||||||
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
|
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
|
||||||
dependencyDownloadFiles.value = []
|
dependencyDownloadFiles.value = []
|
||||||
|
dependencyDownloadFilesLoaded.value = false
|
||||||
downloadProjectResetKey.value += 1
|
downloadProjectResetKey.value += 1
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user