mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 17:14:50 +00:00
feat: download modal changes (#6652)
* update download with modrinth app button * feat: implement compatible version card and update download dependency buttons * fix: download multiple files immediately triggers blocking * refactor: implement download modal provider * fix: duplicate dependencies and style update for dependecy section * feat: improve loading flow when there are dependencies, to not show compatible version until all data is loaded * fix: fallback to download compatible version if dependency lookup fails * feat: implement selecting compatible versions when there are multiple * fix: update dependencies version table to any compatible version * fix: version showing before depedencies loaded * feat: add platform options sort order * pnpm prepr * feat: keep previous result showing until new one loads in when change compatibility options * feat: implement better handling for showing required/recommended resource packs with additional files * pnpm prepr * fix: required resource pack not showing footer actions * fix: download with deps is downloading all files * feat: add joined button for downloading with recommended * feat: add admontion for dependency that has resource pack to download * pnpm prepr * fix: lint * feat: append "-EXTRACT_ME.zip" to zipped project with dependencies * fix: date published capitalization * pnpm prepr --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div
|
||||
:role="selectable ? 'radio' : undefined"
|
||||
:aria-checked="selectable ? selected : undefined"
|
||||
:tabindex="selectable ? 0 : undefined"
|
||||
class="grid items-center gap-3 rounded-2xl border border-solid px-3 py-3 transition-all"
|
||||
:class="{
|
||||
'grid-cols-[min-content_minmax(0,1fr)_min-content]': selectable,
|
||||
'grid-cols-[minmax(0,1fr)_min-content]': !selectable,
|
||||
'cursor-pointer border-brand bg-surface-4 text-contrast': selectable && selected,
|
||||
'cursor-pointer border-surface-5 bg-surface-4 hover:brightness-[115%]':
|
||||
selectable && !selected,
|
||||
'border-transparent bg-surface-2': !selectable,
|
||||
}"
|
||||
@click="select"
|
||||
@keydown.enter.self.prevent="select"
|
||||
@keydown.space.self.prevent="select"
|
||||
>
|
||||
<template v-if="selectable">
|
||||
<RadioButtonCheckedIcon v-if="selected" aria-hidden="true" class="size-5 text-brand" />
|
||||
<RadioButtonIcon v-else aria-hidden="true" class="size-5 text-secondary" />
|
||||
</template>
|
||||
<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">
|
||||
{{ publishedLabel }}
|
||||
</span>
|
||||
<div
|
||||
v-if="primaryFile"
|
||||
class="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-primary opacity-30"
|
||||
></div>
|
||||
<span v-if="primaryFile" class="flex-shrink-0">
|
||||
{{ primaryFileSizeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
v-if="primaryFile && showDownload"
|
||||
: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, RadioButtonCheckedIcon, RadioButtonIcon } 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 { capitalizeString, 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
|
||||
selectable?: boolean
|
||||
selected?: boolean
|
||||
showDownload?: boolean
|
||||
}>(),
|
||||
{
|
||||
downloadReason: 'standalone',
|
||||
currentGameVersion: null,
|
||||
currentPlatform: null,
|
||||
color: 'brand',
|
||||
type: 'standard',
|
||||
circular: false,
|
||||
selectable: false,
|
||||
selected: false,
|
||||
showDownload: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: []
|
||||
select: []
|
||||
}>()
|
||||
|
||||
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(() =>
|
||||
capitalizeString(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)
|
||||
})
|
||||
|
||||
function select() {
|
||||
if (!props.selectable) return
|
||||
emit('select')
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
downloadVersion: {
|
||||
id: 'project.download.download-version',
|
||||
defaultMessage: 'Download {version}',
|
||||
},
|
||||
download: {
|
||||
id: 'project.download.download',
|
||||
defaultMessage: 'Download',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,494 +1,111 @@
|
||||
<template>
|
||||
<div v-if="downloadRows.length > 0" class="flex flex-col gap-1">
|
||||
<div v-if="showTitle" class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="m-0 flex items-center gap-1.5 text-base font-semibold text-contrast">
|
||||
{{ sectionTitle }}
|
||||
<InfoIcon
|
||||
v-if="duplicateDependencyRowsHidden"
|
||||
v-tooltip="formatMessage(messages.duplicateDependenciesHidden)"
|
||||
aria-hidden="true"
|
||||
class="size-4 text-secondary"
|
||||
<div v-if="downloadRows.length > 0 || recommendedRows.length > 0" class="flex flex-col gap-4">
|
||||
<div v-if="downloadRows.length > 0" class="flex flex-col gap-2.5">
|
||||
<div v-if="showTitle" class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="m-0 flex items-center gap-1.5 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.dependenciesTitle) }}
|
||||
<InfoIcon
|
||||
v-if="duplicateDependencyRowsHidden"
|
||||
v-tooltip="formatMessage(messages.duplicateDependenciesHidden)"
|
||||
aria-hidden="true"
|
||||
class="size-4 text-secondary"
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
<Admonition v-if="requiredResourcePackAdmonitionVisible" type="info">
|
||||
<IntlFormatted :message-id="messages.requiredResourcePackAdmonition">
|
||||
<template #folder>
|
||||
<code class="text-sm">resourcepacks</code>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</Admonition>
|
||||
<Admonition v-if="dependencyResourcePackAdmonitionVisible" type="info">
|
||||
<IntlFormatted :message-id="messages.dependencyResourcePackAdmonition">
|
||||
<template #folder>
|
||||
<code class="text-sm">resourcepacks</code>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</Admonition>
|
||||
<div class="rounded-2xl bg-surface-2 p-2 pl-4 pr-3">
|
||||
<DownloadDependency
|
||||
v-for="dependency in downloadRows"
|
||||
:key="dependency.key"
|
||||
:dependency="dependency"
|
||||
@download="emit('download')"
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DownloadDependency
|
||||
v-for="dependency in downloadRows"
|
||||
:key="dependency.key"
|
||||
:dependency="dependency"
|
||||
@download="emit('download')"
|
||||
/>
|
||||
|
||||
<div v-if="recommendedRows.length > 0" class="flex flex-col gap-2.5">
|
||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.recommendedTitle) }}
|
||||
</h3>
|
||||
<div class="rounded-2xl bg-surface-2 p-2 pl-4 pr-3">
|
||||
<DownloadDependency
|
||||
v-for="dependency in recommendedRows"
|
||||
:key="dependency.key"
|
||||
:dependency="dependency"
|
||||
@download="emit('download')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { FileIcon, InfoIcon } from '@modrinth/assets'
|
||||
import {
|
||||
type CdnDownloadReason,
|
||||
defineMessages,
|
||||
fileTypeMessages,
|
||||
injectModrinthClient,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { type Component, computed, watch } from 'vue'
|
||||
import { InfoIcon } from '@modrinth/assets'
|
||||
import { Admonition, defineMessages, IntlFormatted, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { injectDownloadModalProvider } from './download-modal-provider'
|
||||
import DownloadDependency from './DownloadDependency.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'DownloadDependencies',
|
||||
})
|
||||
|
||||
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
|
||||
project_type: DisplayProjectType
|
||||
actualProjectType: Labrinth.Projects.v2.ProjectType
|
||||
}
|
||||
|
||||
type ResolvedContent = Labrinth.Content.v3.ResolvedContent | Labrinth.Content.v3.SkippedContent
|
||||
|
||||
interface DownloadDependencyRow {
|
||||
key: string
|
||||
name: string
|
||||
icon?: string
|
||||
fallbackIcon?: Component
|
||||
projectHref?: string
|
||||
downloadHref?: string
|
||||
filename?: string
|
||||
fileSize?: number
|
||||
typeLabel: string
|
||||
unavailableTooltip: string
|
||||
dependencies: DownloadDependencyRow[]
|
||||
}
|
||||
|
||||
interface DownloadableDependencyFile {
|
||||
href: string
|
||||
filename: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
dependencies?: DownloadDependencyRow[] | null
|
||||
project?: DownloadModalProject | null
|
||||
selectedVersion?: Labrinth.Versions.v3.Version | null
|
||||
currentGameVersion?: string | null
|
||||
currentPlatform?: string | null
|
||||
downloadReason?: CdnDownloadReason
|
||||
additionalFiles?: Labrinth.Versions.v3.VersionFile[]
|
||||
showTitle?: boolean
|
||||
}>(),
|
||||
{
|
||||
dependencies: null,
|
||||
project: null,
|
||||
selectedVersion: null,
|
||||
currentGameVersion: null,
|
||||
currentPlatform: null,
|
||||
downloadReason: 'standalone',
|
||||
additionalFiles: () => [],
|
||||
showTitle: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: []
|
||||
'update:downloadable-files': [files: DownloadableDependencyFile[]]
|
||||
}>()
|
||||
const client = injectModrinthClient()
|
||||
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const shouldResolveDependencies = computed(
|
||||
() => !props.dependencies && !!props.project && !!props.selectedVersion,
|
||||
)
|
||||
|
||||
const dependencyResolutionPreferences = computed<Labrinth.Content.v3.ResolutionPreferences>(() => ({
|
||||
game_versions: props.selectedVersion?.game_versions || [],
|
||||
loaders: props.currentPlatform ? [props.currentPlatform] : props.selectedVersion?.loaders || [],
|
||||
}))
|
||||
|
||||
const { data: dependencyResolution } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'project-download-modal',
|
||||
'content-resolve',
|
||||
props.project?.id,
|
||||
props.selectedVersion?.id,
|
||||
props.project?.project_type,
|
||||
dependencyResolutionPreferences.value,
|
||||
]),
|
||||
queryFn: () =>
|
||||
client.labrinth.content_v3.resolve({
|
||||
project_id: props.project!.id,
|
||||
version_id: props.selectedVersion!.id,
|
||||
content_type: resolveContentType(props.project!.project_type),
|
||||
selected: dependencyResolutionPreferences.value,
|
||||
target: dependencyResolutionPreferences.value,
|
||||
}),
|
||||
enabled: shouldResolveDependencies,
|
||||
})
|
||||
|
||||
const visibleResolvedDependencies = computed<ResolvedContent[]>(() => {
|
||||
return [
|
||||
...(dependencyResolution.value?.dependencies || []),
|
||||
...(dependencyResolution.value?.skipped || []),
|
||||
].filter(shouldShowDependency)
|
||||
})
|
||||
|
||||
const dependencyVersionIds = computed<string[]>(() => {
|
||||
return [
|
||||
...new Set(
|
||||
visibleResolvedDependencies.value
|
||||
.filter((dependency) => !('reason' in dependency))
|
||||
.map((dependency) => dependency.version_id)
|
||||
.filter((versionId): versionId is string => !!versionId),
|
||||
),
|
||||
]
|
||||
})
|
||||
|
||||
const { data: dependencyVersions } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'project-download-modal',
|
||||
'resolved-versions',
|
||||
dependencyVersionIds.value,
|
||||
]),
|
||||
queryFn: () => client.labrinth.versions_v3.getVersions(dependencyVersionIds.value),
|
||||
enabled: computed(() => shouldResolveDependencies.value && dependencyVersionIds.value.length > 0),
|
||||
})
|
||||
|
||||
const dependencyVersionById = computed(() => {
|
||||
const map = new Map<string, Labrinth.Versions.v3.Version>()
|
||||
for (const version of dependencyVersions.value || []) {
|
||||
if (!version) continue
|
||||
map.set(version.id, version)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const dependencyProjectIds = computed<string[]>(() => {
|
||||
return [
|
||||
...new Set(
|
||||
visibleResolvedDependencies.value
|
||||
.map((dependency) => dependency.project_id)
|
||||
.filter((projectId): projectId is string => !!projectId),
|
||||
),
|
||||
]
|
||||
})
|
||||
|
||||
const { data: dependencyProjects } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'project-download-modal',
|
||||
'resolved-projects',
|
||||
dependencyProjectIds.value,
|
||||
]),
|
||||
queryFn: () => client.labrinth.projects_v2.getMultiple(dependencyProjectIds.value),
|
||||
enabled: computed(() => shouldResolveDependencies.value && dependencyProjectIds.value.length > 0),
|
||||
})
|
||||
|
||||
const dependencyProjectById = computed(() => {
|
||||
const map = new Map<string, Labrinth.Projects.v2.Project>()
|
||||
for (const project of dependencyProjects.value || []) {
|
||||
map.set(project.id, project)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const dependenciesByParentVersionId = computed(() => {
|
||||
const map = new Map<string, ResolvedContent[]>()
|
||||
|
||||
for (const dependency of visibleResolvedDependencies.value) {
|
||||
if (!dependency.dependent_on_version_id) continue
|
||||
|
||||
const dependencies = map.get(dependency.dependent_on_version_id) || []
|
||||
dependencies.push(dependency)
|
||||
map.set(dependency.dependent_on_version_id, dependencies)
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const dependenciesLoaded = computed(() => {
|
||||
if (!shouldResolveDependencies.value) return false
|
||||
if (!dependencyResolution.value) return false
|
||||
if (
|
||||
dependencyResolution.value.primary.version_id &&
|
||||
dependencyResolution.value.primary.version_id !== props.selectedVersion?.id
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!dependencyVersionIds.value.every((versionId) => dependencyVersionById.value.has(versionId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!dependencyProjectIds.value.every((projectId) => dependencyProjectById.value.has(projectId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const resolvedDependencyRows = computed<DownloadDependencyRow[]>(() => {
|
||||
if (!dependenciesLoaded.value) return []
|
||||
|
||||
const primaryVersionId =
|
||||
dependencyResolution.value?.primary.version_id || props.selectedVersion?.id
|
||||
if (!primaryVersionId) return []
|
||||
|
||||
const dependencies = dependenciesByParentVersionId.value.get(primaryVersionId) || []
|
||||
|
||||
return dependencies.flatMap((dependency) => {
|
||||
const row = createDependencyRow(dependency)
|
||||
return row ? [row] : []
|
||||
})
|
||||
})
|
||||
|
||||
const dependencyRows = computed<DownloadDependencyRow[]>(
|
||||
() => props.dependencies || resolvedDependencyRows.value,
|
||||
)
|
||||
|
||||
const visibleDependencyRows = computed<DownloadDependencyRow[]>(() =>
|
||||
dedupeDependencyRows(dependencyRows.value),
|
||||
)
|
||||
|
||||
const duplicateDependencyRowsHidden = computed(() =>
|
||||
hasDuplicateDependencyRows(dependencyRows.value),
|
||||
)
|
||||
|
||||
const additionalFileRows = computed<DownloadDependencyRow[]>(() =>
|
||||
props.additionalFiles.map((file) => ({
|
||||
key: `additional-file-${additionalFileKey(file)}`,
|
||||
name: file.filename,
|
||||
fallbackIcon: FileIcon,
|
||||
downloadHref: getDownloadUrl(file.url),
|
||||
filename: file.filename,
|
||||
fileSize: file.size,
|
||||
typeLabel: fileTypeLabel(file.file_type),
|
||||
unavailableTooltip: formatMessage(messages.unavailableFile),
|
||||
dependencies: [],
|
||||
})),
|
||||
)
|
||||
|
||||
const downloadRows = computed<DownloadDependencyRow[]>(() => [
|
||||
...visibleDependencyRows.value,
|
||||
...additionalFileRows.value,
|
||||
])
|
||||
|
||||
const sectionTitle = computed(() =>
|
||||
formatMessage(
|
||||
visibleDependencyRows.value.length > 0
|
||||
? messages.dependenciesTitle
|
||||
: messages.additionalFilesTitle,
|
||||
),
|
||||
)
|
||||
|
||||
const downloadableDependencyFiles = computed<DownloadableDependencyFile[]>(() =>
|
||||
collectDownloadableDependencyFiles(visibleDependencyRows.value),
|
||||
)
|
||||
|
||||
watch(
|
||||
downloadableDependencyFiles,
|
||||
(files) => {
|
||||
emit('update:downloadable-files', files)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function primaryFileForVersion(version?: Labrinth.Versions.v3.Version) {
|
||||
return version?.files?.find((file) => file.primary) || version?.files?.[0]
|
||||
}
|
||||
|
||||
function shouldShowDependency(dependency: ResolvedContent) {
|
||||
return !(
|
||||
'reason' in dependency && ['duplicate_project', 'quilt_fabric_api'].includes(dependency.reason)
|
||||
)
|
||||
}
|
||||
|
||||
function createDependencyRow(dependency: ResolvedContent): DownloadDependencyRow | null {
|
||||
const versionId = dependency.version_id ?? undefined
|
||||
const version = versionId ? dependencyVersionById.value.get(versionId) : undefined
|
||||
const project = dependencyProjectById.value.get(dependency.project_id)
|
||||
if (!project) return null
|
||||
|
||||
const primaryFile = primaryFileForVersion(version)
|
||||
const unavailableTooltip =
|
||||
'reason' in dependency && dependency.reason
|
||||
? skippedReasonLabel(dependency.reason)
|
||||
: formatMessage(messages.unavailableDependency)
|
||||
const name = project.title
|
||||
|
||||
return {
|
||||
key: `${dependency.project_id}-${versionId ?? 'unresolved'}-${
|
||||
'reason' in dependency ? dependency.reason : 'resolved'
|
||||
}`,
|
||||
name,
|
||||
icon: project.icon_url ?? undefined,
|
||||
projectHref: `/${project.project_type}/${project.slug || project.id}`,
|
||||
downloadHref:
|
||||
'reason' in dependency || !primaryFile ? undefined : getDownloadUrl(primaryFile.url),
|
||||
filename: primaryFile?.filename,
|
||||
fileSize: primaryFile?.size,
|
||||
typeLabel: 'Required',
|
||||
unavailableTooltip,
|
||||
dependencies: (versionId && dependenciesByParentVersionId.value.get(versionId)
|
||||
? dependenciesByParentVersionId.value.get(versionId)!
|
||||
: []
|
||||
).flatMap((subDependency) => {
|
||||
const row = createDependencyRow(subDependency)
|
||||
return row ? [row] : []
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function skippedReasonLabel(reason: Labrinth.Content.v3.SkippedContent['reason']) {
|
||||
return (
|
||||
{
|
||||
already_installed: formatMessage(messages.alreadyInstalledDependency),
|
||||
duplicate_project: formatMessage(messages.duplicateDependency),
|
||||
conflicting_dependency: formatMessage(messages.conflictingDependency),
|
||||
no_compatible_version: formatMessage(messages.noCompatibleDependency),
|
||||
missing_version: formatMessage(messages.missingDependencyVersion),
|
||||
quilt_fabric_api: formatMessage(messages.quiltFabricApiDependency),
|
||||
}[reason] || formatMessage(messages.unavailableDependency)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveContentType(projectType: DisplayProjectType): Labrinth.Content.v3.ContentType {
|
||||
return ['mod', 'plugin', 'datapack', 'resourcepack', 'shader', 'modpack'].includes(projectType)
|
||||
? (projectType as Labrinth.Content.v3.ContentType)
|
||||
: 'mod'
|
||||
}
|
||||
|
||||
function getDownloadUrl(url: string) {
|
||||
return createProjectDownloadUrl(url, {
|
||||
reason: props.downloadReason,
|
||||
gameVersion: props.currentGameVersion ?? undefined,
|
||||
loader: props.currentPlatform ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function fileTypeLabel(type?: Labrinth.Versions.v3.FileType | null) {
|
||||
return formatMessage(fileTypeMessages[type ?? 'unknown'] ?? fileTypeMessages.unknown)
|
||||
}
|
||||
|
||||
function additionalFileKey(file: Labrinth.Versions.v3.VersionFile) {
|
||||
return file.hashes?.sha1 ?? file.filename
|
||||
}
|
||||
|
||||
function dedupeDependencyRows(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenDependencies = new Set<string>(),
|
||||
): DownloadDependencyRow[] {
|
||||
return rows.flatMap((row) => {
|
||||
const identity = dependencyRowIdentity(row)
|
||||
if (seenDependencies.has(identity)) return []
|
||||
|
||||
seenDependencies.add(identity)
|
||||
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
dependencies: dedupeDependencyRows(row.dependencies, seenDependencies),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function dependencyRowIdentity(row: DownloadDependencyRow) {
|
||||
return row.projectHref ?? row.downloadHref ?? row.key
|
||||
}
|
||||
|
||||
function hasDuplicateDependencyRows(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenDependencies = new Set<string>(),
|
||||
): boolean {
|
||||
for (const row of rows) {
|
||||
const rowId = dependencyRowIdentity(row)
|
||||
if (seenDependencies.has(rowId)) return true
|
||||
seenDependencies.add(rowId)
|
||||
if (hasDuplicateDependencyRows(row.dependencies, seenDependencies)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function collectDownloadableDependencyFiles(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenHrefs = new Set<string>(),
|
||||
): DownloadableDependencyFile[] {
|
||||
const files: DownloadableDependencyFile[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.downloadHref && !seenHrefs.has(row.downloadHref)) {
|
||||
seenHrefs.add(row.downloadHref)
|
||||
files.push({
|
||||
href: row.downloadHref,
|
||||
filename: row.filename || filenameFromUrl(row.downloadHref),
|
||||
name: row.name,
|
||||
})
|
||||
}
|
||||
|
||||
files.push(...collectDownloadableDependencyFiles(row.dependencies, seenHrefs))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function filenameFromUrl(url: string) {
|
||||
try {
|
||||
const filename = new URL(url).pathname.split('/').pop()
|
||||
return filename ? decodeURIComponent(filename) : 'dependency.jar'
|
||||
} catch {
|
||||
return 'dependency.jar'
|
||||
}
|
||||
}
|
||||
const {
|
||||
dependencyResourcePackAdmonitionVisible,
|
||||
duplicateDependencyRowsHidden,
|
||||
downloadRows,
|
||||
recommendedRows,
|
||||
requiredResourcePackAdmonitionVisible,
|
||||
} = injectDownloadModalProvider()
|
||||
|
||||
const messages = defineMessages({
|
||||
dependenciesTitle: {
|
||||
id: 'project.download.dependencies-title',
|
||||
defaultMessage: 'Dependencies',
|
||||
},
|
||||
recommendedTitle: {
|
||||
id: 'project.download.recommended-title',
|
||||
defaultMessage: 'Recommended',
|
||||
},
|
||||
duplicateDependenciesHidden: {
|
||||
id: 'project.download.duplicate-dependencies-hidden',
|
||||
defaultMessage: 'Duplicate dependencies are hidden',
|
||||
},
|
||||
additionalFilesTitle: {
|
||||
id: 'project.download.additional-files-title',
|
||||
defaultMessage: 'Additional files',
|
||||
requiredResourcePackAdmonition: {
|
||||
id: 'project.download.required-resource-pack-admonition',
|
||||
defaultMessage:
|
||||
'This data pack also requires a resource pack. Download it and place it in your {folder} folder.',
|
||||
},
|
||||
alreadyInstalledDependency: {
|
||||
id: 'project.download.dependency-already-installed',
|
||||
defaultMessage: 'This dependency is already installed',
|
||||
},
|
||||
conflictingDependency: {
|
||||
id: 'project.download.dependency-conflicting',
|
||||
defaultMessage: 'This dependency conflicts with another dependency',
|
||||
},
|
||||
duplicateDependency: {
|
||||
id: 'project.download.dependency-duplicate',
|
||||
defaultMessage: 'This dependency is already included',
|
||||
},
|
||||
missingDependencyVersion: {
|
||||
id: 'project.download.dependency-missing-version',
|
||||
defaultMessage: 'This dependency version is unavailable',
|
||||
},
|
||||
noCompatibleDependency: {
|
||||
id: 'project.download.dependency-no-compatible-version',
|
||||
defaultMessage: 'No compatible version is available for this dependency',
|
||||
},
|
||||
quiltFabricApiDependency: {
|
||||
id: 'project.download.dependency-quilt-fabric-api',
|
||||
defaultMessage: 'Fabric API is skipped for Quilt',
|
||||
},
|
||||
unavailableDependency: {
|
||||
id: 'project.download.dependency-unavailable',
|
||||
defaultMessage: 'This dependency cannot be downloaded',
|
||||
},
|
||||
unavailableFile: {
|
||||
id: 'project.download.file-unavailable',
|
||||
defaultMessage: 'This file cannot be downloaded',
|
||||
dependencyResourcePackAdmonition: {
|
||||
id: 'project.download.dependency-resource-pack-admonition',
|
||||
defaultMessage:
|
||||
'This project has a dependency with a required resource pack. Download it and place it in your {folder} folder.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<div
|
||||
class="grid min-h-10 grid-cols-[minmax(0,1fr)_min-content] items-center gap-3 rounded-xl bg-button-bg py-0 pl-3.5 pr-2 text-primary"
|
||||
class="z-10 grid h-11 grid-cols-[minmax(0,1fr)_min-content] items-center gap-1 text-primary"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-3">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<Avatar
|
||||
v-if="dependency.icon"
|
||||
v-if="dependency.icon && !dependency.hideIcon"
|
||||
:src="dependency.icon"
|
||||
:alt="dependency.name"
|
||||
size="24px"
|
||||
class="!rounded-lg !shadow-none"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex size-4 flex-shrink-0 items-center justify-center rounded-lg border border-solid border-surface-5 text-secondary"
|
||||
v-else-if="!dependency.hideIcon"
|
||||
class="flex size-6 flex-shrink-0 items-center justify-center rounded-lg border border-solid border-surface-5 text-secondary"
|
||||
>
|
||||
<component
|
||||
:is="dependency.fallbackIcon ?? PackageIcon"
|
||||
aria-hidden="true"
|
||||
class="size-5"
|
||||
class="size-4"
|
||||
/>
|
||||
</span>
|
||||
<a
|
||||
@@ -28,7 +28,7 @@
|
||||
:href="dependency.projectHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="min-w-0 truncate text-base font-semibold text-contrast no-underline hover:underline"
|
||||
class="min-w-0 truncate bg-surface-2 text-base font-semibold text-contrast hover:underline"
|
||||
>
|
||||
{{ dependency.name }}
|
||||
</a>
|
||||
@@ -36,13 +36,25 @@
|
||||
v-else
|
||||
ref="dependencyNameRef"
|
||||
v-tooltip="truncatedTooltip(dependencyNameRef, dependency.name)"
|
||||
class="min-w-0 truncate text-base font-semibold text-contrast"
|
||||
class="min-w-0 truncate bg-surface-2 text-base text-contrast"
|
||||
:class="dependency.isAdditionalFile ? 'font-medium' : 'font-semibold'"
|
||||
>
|
||||
{{ dependency.name }}
|
||||
</span>
|
||||
<TagItem class="shrink-0 border !border-solid border-surface-5 !px-3 !py-1 text-base">
|
||||
{{ dependency.typeLabel }}
|
||||
<TagItem
|
||||
v-if="dependency.isAdditionalFile"
|
||||
v-tooltip="metadataTooltip"
|
||||
class="min-w-0 max-w-[50%] shrink-0 truncate border !border-solid border-surface-5"
|
||||
>
|
||||
{{ metadataLabel }}
|
||||
</TagItem>
|
||||
<span
|
||||
v-else
|
||||
v-tooltip="metadataTooltip"
|
||||
class="min-w-0 max-w-[50%] truncate text-sm text-secondary"
|
||||
>
|
||||
{{ metadataLabel }}
|
||||
</span>
|
||||
</span>
|
||||
<ButtonStyled v-if="dependency.downloadHref" circular type="transparent">
|
||||
<a
|
||||
@@ -68,14 +80,18 @@
|
||||
<div
|
||||
v-for="childDependency in dependency.dependencies"
|
||||
:key="childDependency.key"
|
||||
class="group/dependency relative pl-10"
|
||||
class="group/dependency relative pl-8"
|
||||
>
|
||||
<DownloadDependency :dependency="childDependency" class="z-1" @download="emit('download')" />
|
||||
<DownloadDependency
|
||||
:dependency="childDependency"
|
||||
class="relative z-10"
|
||||
@download="emit('download')"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute -top-2 left-6 z-0 h-[calc(100%+1rem)] w-0.5 bg-surface-5 group-first/dependency:-top-2 group-first/dependency:h-20 group-last/dependency:h-7"
|
||||
class="absolute -top-2.5 left-3 z-0 h-full w-0.5 bg-surface-5 group-last/dependency:h-8"
|
||||
/>
|
||||
<div aria-hidden="true" class="absolute left-6 top-5 z-0 h-0.5 w-4 bg-surface-5" />
|
||||
<div aria-hidden="true" class="absolute left-3 top-[21px] z-0 h-0.5 w-7 bg-surface-5" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -102,10 +118,13 @@ interface DownloadDependencyRow {
|
||||
name: string
|
||||
icon?: string
|
||||
fallbackIcon?: Component
|
||||
hideIcon?: boolean
|
||||
isAdditionalFile?: boolean
|
||||
projectHref?: string
|
||||
downloadHref?: string
|
||||
filename?: string
|
||||
fileSize?: number
|
||||
metadataLabel?: string
|
||||
typeLabel: string
|
||||
unavailableTooltip: string
|
||||
dependencies: DownloadDependencyRow[]
|
||||
@@ -123,6 +142,13 @@ const { formatMessage } = useVIntl()
|
||||
const formatBytes = useFormatBytes()
|
||||
const dependencyNameRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const metadataLabel = computed(() => props.dependency.metadataLabel ?? props.dependency.typeLabel)
|
||||
const metadataTooltip = computed(() => {
|
||||
if (props.dependency.isAdditionalFile) return null
|
||||
if (metadataLabel.value === props.dependency.typeLabel) return null
|
||||
return metadataLabel.value
|
||||
})
|
||||
|
||||
const downloadTooltip = computed(() => {
|
||||
const filename = props.dependency.filename || props.dependency.name
|
||||
|
||||
|
||||
@@ -80,107 +80,73 @@
|
||||
</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 && downloadDataLoaded"
|
||||
:role="compatibleVersions.length > 1 ? 'radiogroup' : undefined"
|
||||
:aria-label="
|
||||
compatibleVersions.length > 1 ? formatMessage(messages.compatibleVersionTitle) : undefined
|
||||
"
|
||||
class="flex flex-col gap-2.5"
|
||||
>
|
||||
<h3
|
||||
v-if="compatibleVersions.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
|
||||
v-for="compatibleVersion in compatibleVersions"
|
||||
:key="compatibleVersion.id"
|
||||
:project="project"
|
||||
:version="compatibleVersion"
|
||||
:download-reason="downloadReason"
|
||||
:current-game-version="currentGameVersion"
|
||||
:current-platform="currentPlatform"
|
||||
:selectable="compatibleVersions.length > 1"
|
||||
:selected="compatibleVersion.id === selectedVersion.id"
|
||||
:show-download="
|
||||
compatibleVersions.length === 1 || compatibleVersion.id === selectedVersion.id
|
||||
"
|
||||
:color="
|
||||
compatibleVersion.id === selectedVersion.id &&
|
||||
compatibleVersions.length === 1 &&
|
||||
!hasAdditionalDownloads
|
||||
? 'brand'
|
||||
: 'standard'
|
||||
"
|
||||
:type="
|
||||
compatibleVersion.id === selectedVersion.id &&
|
||||
compatibleVersions.length === 1 &&
|
||||
!hasAdditionalDownloads
|
||||
? 'standard'
|
||||
: 'transparent'
|
||||
"
|
||||
:circular="hasAdditionalDownloads || compatibleVersions.length > 1"
|
||||
@select="selectCompatibleVersion(compatibleVersion)"
|
||||
@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',
|
||||
})
|
||||
@@ -207,6 +173,7 @@ const props = withDefaults(
|
||||
project: DownloadModalProject
|
||||
versions?: Labrinth.Versions.v3.Version[]
|
||||
dependencyDownloadFiles?: DownloadableFile[]
|
||||
downloadDataLoaded?: boolean
|
||||
downloadReason?: CdnDownloadReason
|
||||
initialGameVersion?: string | null
|
||||
initialPlatform?: string | null
|
||||
@@ -217,6 +184,7 @@ const props = withDefaults(
|
||||
{
|
||||
versions: () => [],
|
||||
dependencyDownloadFiles: () => [],
|
||||
downloadDataLoaded: false,
|
||||
downloadReason: 'standalone',
|
||||
initialGameVersion: null,
|
||||
initialPlatform: null,
|
||||
@@ -233,22 +201,19 @@ const emit = defineEmits<{
|
||||
'update:selection': [selection: ProjectDownloadSelection]
|
||||
}>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const debug = useDebugLogger('DownloadProject')
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const userSelectedGameVersion = ref<string | null>(props.initialGameVersion)
|
||||
const userSelectedPlatform = ref<string | null>(props.initialPlatform)
|
||||
const userSelectedCompatibleVersionId = ref<string | null>(null)
|
||||
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 preferredPlatformRanks = new Map([
|
||||
['fabric', 0],
|
||||
['forge', 1],
|
||||
['neoforge', 2],
|
||||
])
|
||||
|
||||
const incompatibleGameVersionsSet = computed(() => new Set(props.incompatibleGameVersions))
|
||||
const incompatibleLoadersSet = computed(() => new Set(props.incompatibleLoaders))
|
||||
@@ -376,13 +341,12 @@ const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
|
||||
|
||||
const platformOptions = computed<ComboboxOption<string>[]>(() => {
|
||||
return props.project.loaders
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((platform) => ({
|
||||
value: platform,
|
||||
label: loaderLabel(platform),
|
||||
class: '!px-0 !py-1',
|
||||
}))
|
||||
.sort(comparePlatformOptions)
|
||||
})
|
||||
|
||||
const filteredVersions = computed<Labrinth.Versions.v3.Version[]>(() => {
|
||||
@@ -407,31 +371,52 @@ 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 defaultSelectedVersion = computed<Labrinth.Versions.v3.Version | null>(() => {
|
||||
return filteredRelease.value || filteredBeta.value || filteredAlpha.value || null
|
||||
})
|
||||
|
||||
const suggestedPreReleaseVersions = computed<Labrinth.Versions.v3.Version[]>(() => {
|
||||
if (!defaultSelectedVersion.value || defaultSelectedVersion.value.version_type !== 'release')
|
||||
return []
|
||||
|
||||
const versions: Labrinth.Versions.v3.Version[] = []
|
||||
const beta = filteredBeta.value
|
||||
if (beta && isNewerThan(beta, defaultSelectedVersion.value)) {
|
||||
versions.push(beta)
|
||||
}
|
||||
|
||||
const alpha = filteredAlpha.value
|
||||
if (alpha && isNewerThan(alpha, defaultSelectedVersion.value)) {
|
||||
versions.push(alpha)
|
||||
}
|
||||
|
||||
return versions
|
||||
})
|
||||
|
||||
const compatibleVersions = computed<Labrinth.Versions.v3.Version[]>(() => {
|
||||
if (!defaultSelectedVersion.value) return []
|
||||
return [defaultSelectedVersion.value, ...suggestedPreReleaseVersions.value]
|
||||
})
|
||||
|
||||
const selectedVersion = computed<Labrinth.Versions.v3.Version | null>(() => {
|
||||
return filteredRelease.value || filteredBeta.value || filteredAlpha.value || null
|
||||
return (
|
||||
compatibleVersions.value.find(
|
||||
(version) => version.id === userSelectedCompatibleVersionId.value,
|
||||
) ||
|
||||
defaultSelectedVersion.value ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const selectedPrimaryFile = computed<Labrinth.Versions.v3.VersionFile | null>(() => {
|
||||
@@ -442,39 +427,47 @@ const selectedPrimaryFile = computed<Labrinth.Versions.v3.VersionFile | null>(()
|
||||
)
|
||||
})
|
||||
|
||||
const selectedPrimaryFileDownloadUrl = computed(() => {
|
||||
if (!selectedPrimaryFile.value) return '#'
|
||||
return getDownloadUrl(selectedPrimaryFile.value.url)
|
||||
const requiredResourcePackFile = computed<Labrinth.Versions.v3.VersionFile | null>(() => {
|
||||
if (props.project.project_type !== 'datapack') return null
|
||||
|
||||
return (
|
||||
selectedVersion.value?.files?.find(
|
||||
(file) => file !== selectedPrimaryFile.value && file.file_type === 'required-resource-pack',
|
||||
) || null
|
||||
)
|
||||
})
|
||||
|
||||
const selectedVersionDownloadFiles = computed(() => {
|
||||
if (!selectedVersion.value) return []
|
||||
const recommendedResourcePackFiles = computed<Labrinth.Versions.v3.VersionFile[]>(() => {
|
||||
if (props.project.project_type !== 'datapack') return []
|
||||
|
||||
return selectedVersion.value.files.map((file) => ({
|
||||
href: getDownloadUrl(file.url),
|
||||
filename: file.filename,
|
||||
}))
|
||||
return (
|
||||
selectedVersion.value?.files?.filter(
|
||||
(file) => file !== selectedPrimaryFile.value && file.file_type === 'optional-resource-pack',
|
||||
) || []
|
||||
)
|
||||
})
|
||||
|
||||
const downloadAllFiles = computed(() => {
|
||||
const files: DownloadableFile[] = []
|
||||
const hasAdditionalDownloads = computed(() => {
|
||||
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)
|
||||
if (selectedPrimaryFile.value) {
|
||||
hrefs.add(selectedPrimaryFile.value.url)
|
||||
}
|
||||
|
||||
return files
|
||||
})
|
||||
if (requiredResourcePackFile.value) {
|
||||
hrefs.add(requiredResourcePackFile.value.url)
|
||||
}
|
||||
|
||||
const selectedVersionZipFilename = computed(() => {
|
||||
if (!selectedVersion.value) return `${sanitizeFilename(props.project.title)}.zip`
|
||||
for (const file of recommendedResourcePackFiles.value) {
|
||||
hrefs.add(file.url)
|
||||
}
|
||||
|
||||
return `${sanitizeFilename(props.project.title)} ${sanitizeFilename(
|
||||
selectedVersion.value.version_number,
|
||||
)}.zip`
|
||||
for (const file of props.dependencyDownloadFiles) {
|
||||
if (hrefs.has(file.href)) continue
|
||||
hrefs.add(file.href)
|
||||
}
|
||||
|
||||
return hrefs.size > 1
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -490,11 +483,16 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch([currentGameVersion, currentPlatform], () => {
|
||||
userSelectedCompatibleVersionId.value = null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.resetKey,
|
||||
() => {
|
||||
userSelectedGameVersion.value = props.initialGameVersion
|
||||
userSelectedPlatform.value = props.initialPlatform
|
||||
userSelectedCompatibleVersionId.value = null
|
||||
showAllVersions.value = defaultShowAllVersions()
|
||||
versionFilter.value = ''
|
||||
},
|
||||
@@ -513,115 +511,39 @@ 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 selectCompatibleVersion(version: Labrinth.Versions.v3.Version) {
|
||||
userSelectedCompatibleVersionId.value = version.id
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
return formatMessage(getTagMessage(loader, 'loader') ?? messages.unknownLoader)
|
||||
}
|
||||
|
||||
function comparePlatformOptions(a: ComboboxOption<string>, b: ComboboxOption<string>) {
|
||||
const aRank = preferredPlatformRanks.get(a.value) ?? Number.MAX_SAFE_INTEGER
|
||||
const bRank = preferredPlatformRanks.get(b.value) ?? Number.MAX_SAFE_INTEGER
|
||||
|
||||
if (aRank !== bRank) return aRank - bRank
|
||||
|
||||
return a.label.localeCompare(b.label)
|
||||
}
|
||||
|
||||
function isReleaseGameVersion(version: string) {
|
||||
if (releaseVersions.value.has(version)) return true
|
||||
if (nonReleaseVersions.value.has(version)) return false
|
||||
@@ -706,38 +628,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}',
|
||||
|
||||
@@ -6,29 +6,19 @@
|
||||
"
|
||||
class="modrinth-app-section contents"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<a
|
||||
class="modrinth-app-install-card flex items-center justify-between gap-3 rounded-2xl border border-solid border-brand-highlight bg-surface-1 px-4 py-3 text-primary no-underline transition-[filter] hover:brightness-110"
|
||||
:href="`modrinth://mod/${project.slug}`"
|
||||
@click="installWithApp"
|
||||
>
|
||||
<span class="flex w-full min-w-0 flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex min-w-0 items-center gap-1.5 font-medium text-contrast">
|
||||
Install with
|
||||
<span class="text-brand">Modrinth App</span>
|
||||
<ModrinthIcon aria-hidden="true" class="size-4 flex-shrink-0 text-brand" />
|
||||
</span>
|
||||
<ExternalIcon
|
||||
aria-hidden="true"
|
||||
class="size-4 flex-shrink-0 text-contrast transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<span class="truncate text-base text-contrast opacity-80">
|
||||
{{ formatMessage(messages.installWithModrinthAppDescription) }}
|
||||
<div class="flex flex-col items-center">
|
||||
<ButtonStyled color="brand">
|
||||
<a
|
||||
class="!min-h-10 w-fit no-underline"
|
||||
:href="`modrinth://mod/${project.slug}`"
|
||||
@click="installWithApp"
|
||||
>
|
||||
<ModrinthIcon aria-hidden="true" />
|
||||
<span class="min-w-0 text-center">
|
||||
{{ formatMessage(messages.installWithModrinthApp) }}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<Accordion ref="getModrinthAppAccordion">
|
||||
<nuxt-link class="mt-2 flex justify-center text-brand-blue hover:underline" to="/app">
|
||||
{{ formatMessage(messages.dontHaveModrinthApp) }}
|
||||
@@ -48,8 +38,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ExternalIcon, ModrinthIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { ModrinthIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
@@ -73,6 +63,10 @@ const tags = useGeneratedState()
|
||||
const getModrinthAppAccordion = ref<InstanceType<typeof Accordion> | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
installWithModrinthApp: {
|
||||
id: 'project.download.install-with-app',
|
||||
defaultMessage: 'Install with Modrinth App',
|
||||
},
|
||||
dontHaveModrinthApp: {
|
||||
id: 'project.download.no-app',
|
||||
defaultMessage: "Don't have Modrinth App?",
|
||||
@@ -81,10 +75,6 @@ const messages = defineMessages({
|
||||
id: 'project.download.manually',
|
||||
defaultMessage: 'Download manually',
|
||||
},
|
||||
installWithModrinthAppDescription: {
|
||||
id: 'project.download.install-with-app-description',
|
||||
defaultMessage: 'Automatically install the correct version and dependencies.',
|
||||
},
|
||||
})
|
||||
|
||||
function installWithApp() {
|
||||
@@ -95,14 +85,6 @@ function installWithApp() {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modrinth-app-install-card {
|
||||
background: radial-gradient(
|
||||
ellipse 90% 250% at 50% 200%,
|
||||
color-mix(in srgb, var(--color-brand-shadow) 50%, var(--surface-1)) -30%,
|
||||
var(--surface-1) 72%
|
||||
);
|
||||
}
|
||||
|
||||
@media (hover: none) and (max-width: 767px) {
|
||||
.modrinth-app-section {
|
||||
display: none;
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
type CdnDownloadReason,
|
||||
createContext,
|
||||
defineMessages,
|
||||
fileTypeMessages,
|
||||
injectModrinthClient,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { type Component, computed, type ComputedRef } from 'vue'
|
||||
|
||||
import { STALE_TIME } from '~/composables/queries/project'
|
||||
|
||||
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
|
||||
project_type: DisplayProjectType
|
||||
actualProjectType: Labrinth.Projects.v2.ProjectType
|
||||
}
|
||||
|
||||
type ResolvedContent = Labrinth.Content.v3.ResolvedContent | Labrinth.Content.v3.SkippedContent
|
||||
|
||||
export interface DownloadDependencyRow {
|
||||
key: string
|
||||
name: string
|
||||
icon?: string
|
||||
fallbackIcon?: Component
|
||||
hideIcon?: boolean
|
||||
isAdditionalFile?: boolean
|
||||
projectHref?: string
|
||||
downloadHref?: string
|
||||
filename?: string
|
||||
fileSize?: number
|
||||
metadataLabel?: string
|
||||
typeLabel: string
|
||||
unavailableTooltip: string
|
||||
dependencies: DownloadDependencyRow[]
|
||||
}
|
||||
|
||||
export interface DownloadableDependencyFile {
|
||||
href: string
|
||||
filename: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ProjectDownloadSelection {
|
||||
currentGameVersion: string | null
|
||||
currentPlatform: string | null
|
||||
selectedVersion: Labrinth.Versions.v3.Version | null
|
||||
selectedPrimaryFile: Labrinth.Versions.v3.VersionFile | null
|
||||
}
|
||||
|
||||
interface DownloadModalProviderOptions {
|
||||
project: ComputedRef<DownloadModalProject | null>
|
||||
selectedVersion: ComputedRef<Labrinth.Versions.v3.Version | null>
|
||||
selectedPrimaryFile: ComputedRef<Labrinth.Versions.v3.VersionFile | null>
|
||||
currentGameVersion: ComputedRef<string | null>
|
||||
currentPlatform: ComputedRef<string | null>
|
||||
downloadReason: ComputedRef<CdnDownloadReason>
|
||||
additionalFiles: ComputedRef<Labrinth.Versions.v3.VersionFile[]>
|
||||
}
|
||||
|
||||
export interface DownloadModalProvider {
|
||||
visibleDependencyRows: ComputedRef<DownloadDependencyRow[]>
|
||||
duplicateDependencyRowsHidden: ComputedRef<boolean>
|
||||
downloadRows: ComputedRef<DownloadDependencyRow[]>
|
||||
recommendedRows: ComputedRef<DownloadDependencyRow[]>
|
||||
downloadRowsLoaded: ComputedRef<boolean>
|
||||
requiredResourcePackAdmonitionVisible: ComputedRef<boolean>
|
||||
dependencyResourcePackAdmonitionVisible: ComputedRef<boolean>
|
||||
downloadableDependencyFiles: ComputedRef<DownloadableDependencyFile[]>
|
||||
downloadableDependencyFilesLoaded: ComputedRef<boolean>
|
||||
preloadDependenciesForSelection: (selection: ProjectDownloadSelection) => Promise<void>
|
||||
}
|
||||
|
||||
export const [injectDownloadModalProvider, provideDownloadModalContext] =
|
||||
createContext<DownloadModalProvider>('DownloadModal')
|
||||
|
||||
export function provideDownloadModalProvider(
|
||||
options: DownloadModalProviderOptions,
|
||||
): DownloadModalProvider {
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const shouldResolveDependencies = computed(
|
||||
() => !!options.project.value && !!options.selectedVersion.value,
|
||||
)
|
||||
const dependencyResolutionPreferences = computed(() =>
|
||||
createResolutionPreferences(options.selectedVersion.value, options.currentPlatform.value),
|
||||
)
|
||||
|
||||
const { data: dependencyResolution, isFetching: dependencyResolutionFetching } = useQuery({
|
||||
...dependencyResolutionQueryOptions(
|
||||
client,
|
||||
options.project,
|
||||
options.selectedVersion,
|
||||
dependencyResolutionPreferences,
|
||||
),
|
||||
enabled: shouldResolveDependencies,
|
||||
})
|
||||
|
||||
const visibleResolvedDependencies = computed<ResolvedContent[]>(() =>
|
||||
visibleDependencies(dependencyResolution.value),
|
||||
)
|
||||
|
||||
const dependencyVersionIds = computed(() =>
|
||||
sortedUnique(
|
||||
visibleResolvedDependencies.value
|
||||
.filter((dependency) => !('reason' in dependency))
|
||||
.map((dependency) => dependency.version_id)
|
||||
.filter((versionId): versionId is string => !!versionId),
|
||||
),
|
||||
)
|
||||
|
||||
const { data: dependencyVersions, isFetching: dependencyVersionsFetching } = useQuery({
|
||||
...dependencyVersionsQueryOptions(client, dependencyVersionIds),
|
||||
enabled: computed(
|
||||
() => shouldResolveDependencies.value && dependencyVersionIds.value.length > 0,
|
||||
),
|
||||
})
|
||||
|
||||
const dependencyVersionById = computed(() => {
|
||||
const map = new Map<string, Labrinth.Versions.v3.Version>()
|
||||
for (const version of dependencyVersions.value || []) {
|
||||
if (!version) continue
|
||||
map.set(version.id, version)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const dependencyProjectIds = computed(() =>
|
||||
sortedUnique(
|
||||
visibleResolvedDependencies.value
|
||||
.map((dependency) => dependency.project_id)
|
||||
.filter((projectId): projectId is string => !!projectId),
|
||||
),
|
||||
)
|
||||
|
||||
const { data: dependencyProjects, isFetching: dependencyProjectsFetching } = useQuery({
|
||||
...dependencyProjectsQueryOptions(client, dependencyProjectIds),
|
||||
enabled: computed(
|
||||
() => shouldResolveDependencies.value && dependencyProjectIds.value.length > 0,
|
||||
),
|
||||
})
|
||||
|
||||
const dependencyProjectById = computed(() => {
|
||||
const map = new Map<string, Labrinth.Projects.v2.Project>()
|
||||
for (const project of dependencyProjects.value || []) {
|
||||
map.set(project.id, project)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const dependenciesByParentVersionId = computed(() => {
|
||||
const map = new Map<string, ResolvedContent[]>()
|
||||
|
||||
for (const dependency of visibleResolvedDependencies.value) {
|
||||
if (!dependency.dependent_on_version_id) continue
|
||||
|
||||
const dependencies = map.get(dependency.dependent_on_version_id) || []
|
||||
dependencies.push(dependency)
|
||||
map.set(dependency.dependent_on_version_id, dependencies)
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const dependenciesLoaded = computed(() => {
|
||||
if (!shouldResolveDependencies.value) return false
|
||||
if (dependencyResolutionFetching.value) return false
|
||||
if (!dependencyResolution.value) return false
|
||||
if (
|
||||
dependencyResolution.value.primary.version_id &&
|
||||
dependencyResolution.value.primary.version_id !== options.selectedVersion.value?.id
|
||||
) {
|
||||
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
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const selectedDownloadRowsLoaded = computed(() => {
|
||||
if (!options.selectedPrimaryFile.value) return false
|
||||
return dependenciesLoaded.value
|
||||
})
|
||||
const keepPreviousDownloadRows = computed(
|
||||
() => shouldResolveDependencies.value && !!options.selectedPrimaryFile.value,
|
||||
)
|
||||
|
||||
const resolvedDependencyRows = computed<DownloadDependencyRow[]>(() => {
|
||||
if (!selectedDownloadRowsLoaded.value) return []
|
||||
|
||||
const primaryVersionId =
|
||||
dependencyResolution.value?.primary.version_id || options.selectedVersion.value?.id
|
||||
if (!primaryVersionId) return []
|
||||
|
||||
const dependencies = dependenciesByParentVersionId.value.get(primaryVersionId) || []
|
||||
|
||||
return dependencies.flatMap((dependency) => {
|
||||
const row = createDependencyRow(dependency)
|
||||
return row ? [row] : []
|
||||
})
|
||||
})
|
||||
|
||||
const visibleDependencyRows = computed<DownloadDependencyRow[]>((previous) => {
|
||||
if (selectedDownloadRowsLoaded.value) {
|
||||
return dedupeDependencyRows(resolvedDependencyRows.value)
|
||||
}
|
||||
|
||||
return keepPreviousDownloadRows.value ? (previous ?? []) : []
|
||||
})
|
||||
|
||||
const duplicateDependencyRowsHidden = computed<boolean>((previous) => {
|
||||
if (selectedDownloadRowsLoaded.value) {
|
||||
return (
|
||||
hasSkippedDuplicateDependency(dependencyResolution.value) ||
|
||||
hasDuplicateDependencyRows(resolvedDependencyRows.value)
|
||||
)
|
||||
}
|
||||
|
||||
return keepPreviousDownloadRows.value ? (previous ?? false) : false
|
||||
})
|
||||
|
||||
const visibleRequiredResourcePackFiles = computed(() => {
|
||||
if (options.project.value?.project_type !== 'datapack') return []
|
||||
|
||||
return options.additionalFiles.value.filter(
|
||||
(file) => file.file_type === 'required-resource-pack',
|
||||
)
|
||||
})
|
||||
|
||||
const visibleRecommendedResourcePackFiles = computed(() => {
|
||||
if (options.project.value?.project_type !== 'datapack') return []
|
||||
|
||||
return options.additionalFiles.value.filter(
|
||||
(file) => file.file_type === 'optional-resource-pack',
|
||||
)
|
||||
})
|
||||
|
||||
const additionalFileRows = computed<DownloadDependencyRow[]>(() =>
|
||||
selectedDownloadRowsLoaded.value
|
||||
? visibleRequiredResourcePackFiles.value.map(createAdditionalFileRow)
|
||||
: [],
|
||||
)
|
||||
|
||||
const recommendedRows = computed<DownloadDependencyRow[]>(() =>
|
||||
selectedDownloadRowsLoaded.value
|
||||
? visibleRecommendedResourcePackFiles.value.map(createAdditionalFileRow)
|
||||
: [],
|
||||
)
|
||||
|
||||
function createAdditionalFileRow(file: Labrinth.Versions.v3.VersionFile): DownloadDependencyRow {
|
||||
return {
|
||||
key: `additional-file-${additionalFileKey(file)}`,
|
||||
name: file.filename,
|
||||
hideIcon: true,
|
||||
isAdditionalFile: true,
|
||||
downloadHref: getDownloadUrl(file.url),
|
||||
filename: file.filename,
|
||||
fileSize: file.size,
|
||||
metadataLabel: fileTypeDisplayLabel(file.file_type),
|
||||
typeLabel: fileTypeLabel(file.file_type),
|
||||
unavailableTooltip: formatMessage(messages.unavailableFile),
|
||||
dependencies: [],
|
||||
}
|
||||
}
|
||||
|
||||
const downloadRows = computed<DownloadDependencyRow[]>((previous) => {
|
||||
if (selectedDownloadRowsLoaded.value) {
|
||||
return [...visibleDependencyRows.value, ...additionalFileRows.value]
|
||||
}
|
||||
|
||||
return keepPreviousDownloadRows.value ? (previous ?? []) : []
|
||||
})
|
||||
|
||||
const downloadRowsLoaded = computed<boolean>((previous) => {
|
||||
if (selectedDownloadRowsLoaded.value) return true
|
||||
return keepPreviousDownloadRows.value ? (previous ?? false) : false
|
||||
})
|
||||
|
||||
const requiredResourcePackAdmonitionVisible = computed(() => {
|
||||
return selectedDownloadRowsLoaded.value && visibleRequiredResourcePackFiles.value.length > 0
|
||||
})
|
||||
|
||||
const dependencyResourcePackAdmonitionVisible = computed(() => {
|
||||
return selectedDownloadRowsLoaded.value && hasAdditionalFileRows(visibleDependencyRows.value)
|
||||
})
|
||||
|
||||
const downloadableDependencyFiles = computed<DownloadableDependencyFile[]>(() =>
|
||||
collectDownloadableDependencyFiles(visibleDependencyRows.value),
|
||||
)
|
||||
|
||||
const downloadableDependencyFilesLoaded = computed(() => {
|
||||
return selectedDownloadRowsLoaded.value
|
||||
})
|
||||
|
||||
async function preloadDependenciesForSelection(selection: ProjectDownloadSelection) {
|
||||
if (!options.project.value || !selection.selectedVersion) return
|
||||
|
||||
const preferences = createResolutionPreferences(
|
||||
selection.selectedVersion,
|
||||
selection.currentPlatform,
|
||||
)
|
||||
|
||||
const resolution = await queryClient.ensureQueryData({
|
||||
queryKey: [
|
||||
'project-download-modal',
|
||||
'content-resolve',
|
||||
options.project.value.id,
|
||||
selection.selectedVersion.id,
|
||||
options.project.value.project_type,
|
||||
preferences,
|
||||
],
|
||||
queryFn: () =>
|
||||
client.labrinth.content_v3.resolve({
|
||||
project_id: options.project.value!.id,
|
||||
version_id: selection.selectedVersion!.id,
|
||||
content_type: resolveContentType(options.project.value!.project_type),
|
||||
selected: preferences,
|
||||
target: preferences,
|
||||
}),
|
||||
staleTime: STALE_TIME,
|
||||
})
|
||||
const visible = visibleDependencies(resolution)
|
||||
const versionIds = getDependencyVersionIds(visible)
|
||||
const projectIds = getDependencyProjectIds(visible)
|
||||
|
||||
await Promise.all([
|
||||
versionIds.length > 0
|
||||
? queryClient.ensureQueryData({
|
||||
queryKey: ['project-download-modal', 'resolved-versions', versionIds],
|
||||
queryFn: () => client.labrinth.versions_v3.getVersions(versionIds),
|
||||
staleTime: STALE_TIME,
|
||||
})
|
||||
: Promise.resolve(),
|
||||
projectIds.length > 0
|
||||
? queryClient.ensureQueryData({
|
||||
queryKey: ['project-download-modal', 'resolved-projects', projectIds],
|
||||
queryFn: () => client.labrinth.projects_v2.getMultiple(projectIds),
|
||||
staleTime: STALE_TIME,
|
||||
})
|
||||
: Promise.resolve(),
|
||||
])
|
||||
}
|
||||
|
||||
function createDependencyRow(dependency: ResolvedContent): DownloadDependencyRow | null {
|
||||
const versionId = dependency.version_id ?? undefined
|
||||
const version = versionId ? dependencyVersionById.value.get(versionId) : undefined
|
||||
const project = dependencyProjectById.value.get(dependency.project_id)
|
||||
if (!project) return null
|
||||
|
||||
const primaryFile = primaryFileForVersion(version)
|
||||
const unavailableTooltip =
|
||||
'reason' in dependency && dependency.reason
|
||||
? skippedReasonLabel(dependency.reason)
|
||||
: formatMessage(messages.unavailableDependency)
|
||||
const name = project.title
|
||||
const metadataLabel = isProjectOnlyDependencyReference(dependency)
|
||||
? formatMessage(messages.anyCompatibleDependency)
|
||||
: (version?.version_number ?? formatMessage(messages.anyCompatibleDependency))
|
||||
const childDependencies = (
|
||||
versionId && dependenciesByParentVersionId.value.get(versionId)
|
||||
? dependenciesByParentVersionId.value.get(versionId)!
|
||||
: []
|
||||
).flatMap((subDependency) => {
|
||||
const row = createDependencyRow(subDependency)
|
||||
return row ? [row] : []
|
||||
})
|
||||
|
||||
return {
|
||||
key: `${dependency.project_id}-${versionId ?? 'unresolved'}-${
|
||||
'reason' in dependency ? dependency.reason : 'resolved'
|
||||
}`,
|
||||
name,
|
||||
icon: project.icon_url ?? undefined,
|
||||
projectHref: `/${project.project_type}/${project.slug || project.id}`,
|
||||
downloadHref:
|
||||
'reason' in dependency || !primaryFile ? undefined : getDownloadUrl(primaryFile.url),
|
||||
filename: primaryFile?.filename,
|
||||
fileSize: primaryFile?.size,
|
||||
metadataLabel,
|
||||
typeLabel: 'Required',
|
||||
unavailableTooltip,
|
||||
dependencies: [
|
||||
...childDependencies,
|
||||
...createRequiredResourcePackRowsForDependency(project, version, primaryFile),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function createRequiredResourcePackRowsForDependency(
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
version: Labrinth.Versions.v3.Version | undefined,
|
||||
primaryFile: Labrinth.Versions.v3.VersionFile | undefined,
|
||||
): DownloadDependencyRow[] {
|
||||
if (!version || !isDataPackProject(project)) return []
|
||||
|
||||
return version.files
|
||||
.filter((file) => file !== primaryFile && file.file_type === 'required-resource-pack')
|
||||
.map((file) => ({
|
||||
key: `dependency-resource-pack-${version.id}-${additionalFileKey(file)}`,
|
||||
name: file.filename,
|
||||
hideIcon: true,
|
||||
isAdditionalFile: true,
|
||||
downloadHref: getDownloadUrl(file.url),
|
||||
filename: file.filename,
|
||||
fileSize: file.size,
|
||||
metadataLabel: fileTypeDisplayLabel(file.file_type),
|
||||
typeLabel: fileTypeLabel(file.file_type),
|
||||
unavailableTooltip: formatMessage(messages.unavailableFile),
|
||||
dependencies: [],
|
||||
}))
|
||||
}
|
||||
|
||||
function isDataPackProject(project: Labrinth.Projects.v2.Project) {
|
||||
return (
|
||||
project.project_type === 'datapack' ||
|
||||
project.loaders.some((loader) => tags.value.loaderData.dataPackLoaders.includes(loader))
|
||||
)
|
||||
}
|
||||
|
||||
function isProjectOnlyDependencyReference(dependency: ResolvedContent) {
|
||||
const parentVersionId = dependency.dependent_on_version_id
|
||||
const parentVersion =
|
||||
parentVersionId === options.selectedVersion.value?.id
|
||||
? options.selectedVersion.value
|
||||
: parentVersionId
|
||||
? dependencyVersionById.value.get(parentVersionId)
|
||||
: undefined
|
||||
|
||||
return !!parentVersion?.dependencies?.some(
|
||||
(parentDependency) =>
|
||||
parentDependency.project_id === dependency.project_id && !parentDependency.version_id,
|
||||
)
|
||||
}
|
||||
|
||||
function skippedReasonLabel(reason: Labrinth.Content.v3.SkippedContent['reason']) {
|
||||
return (
|
||||
{
|
||||
already_installed: formatMessage(messages.alreadyInstalledDependency),
|
||||
duplicate_project: formatMessage(messages.duplicateDependency),
|
||||
conflicting_dependency: formatMessage(messages.conflictingDependency),
|
||||
no_compatible_version: formatMessage(messages.noCompatibleDependency),
|
||||
missing_version: formatMessage(messages.missingDependencyVersion),
|
||||
quilt_fabric_api: formatMessage(messages.quiltFabricApiDependency),
|
||||
}[reason] || formatMessage(messages.unavailableDependency)
|
||||
)
|
||||
}
|
||||
|
||||
function getDownloadUrl(url: string) {
|
||||
return createProjectDownloadUrl(url, {
|
||||
reason: options.downloadReason.value,
|
||||
gameVersion: options.currentGameVersion.value ?? undefined,
|
||||
loader: options.currentPlatform.value ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function fileTypeLabel(type?: Labrinth.Versions.v3.FileType | null) {
|
||||
return formatMessage(fileTypeMessages[type ?? 'unknown'] ?? fileTypeMessages.unknown)
|
||||
}
|
||||
|
||||
function fileTypeDisplayLabel(type?: Labrinth.Versions.v3.FileType | null) {
|
||||
if (type === 'required-resource-pack') return formatMessage(messages.requiredResourcePackShort)
|
||||
|
||||
return fileTypeLabel(type)
|
||||
}
|
||||
|
||||
const provider = {
|
||||
visibleDependencyRows,
|
||||
duplicateDependencyRowsHidden,
|
||||
downloadRows,
|
||||
recommendedRows,
|
||||
downloadRowsLoaded,
|
||||
requiredResourcePackAdmonitionVisible,
|
||||
dependencyResourcePackAdmonitionVisible,
|
||||
downloadableDependencyFiles,
|
||||
downloadableDependencyFilesLoaded,
|
||||
preloadDependenciesForSelection,
|
||||
}
|
||||
|
||||
provideDownloadModalContext(provider)
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
function dependencyResolutionQueryOptions(
|
||||
client: AbstractModrinthClient,
|
||||
project: ComputedRef<DownloadModalProject | null>,
|
||||
selectedVersion: ComputedRef<Labrinth.Versions.v3.Version | null>,
|
||||
preferences: ComputedRef<Labrinth.Content.v3.ResolutionPreferences>,
|
||||
) {
|
||||
return {
|
||||
queryKey: computed(() => [
|
||||
'project-download-modal',
|
||||
'content-resolve',
|
||||
project.value?.id,
|
||||
selectedVersion.value?.id,
|
||||
project.value?.project_type,
|
||||
preferences.value,
|
||||
]),
|
||||
queryFn: () =>
|
||||
client.labrinth.content_v3.resolve({
|
||||
project_id: project.value!.id,
|
||||
version_id: selectedVersion.value!.id,
|
||||
content_type: resolveContentType(project.value!.project_type),
|
||||
selected: preferences.value,
|
||||
target: preferences.value,
|
||||
}),
|
||||
staleTime: STALE_TIME,
|
||||
}
|
||||
}
|
||||
|
||||
function dependencyVersionsQueryOptions(
|
||||
client: AbstractModrinthClient,
|
||||
versionIds: ComputedRef<string[]>,
|
||||
) {
|
||||
return {
|
||||
queryKey: computed(() => ['project-download-modal', 'resolved-versions', versionIds.value]),
|
||||
queryFn: () => client.labrinth.versions_v3.getVersions(versionIds.value),
|
||||
staleTime: STALE_TIME,
|
||||
}
|
||||
}
|
||||
|
||||
function dependencyProjectsQueryOptions(
|
||||
client: AbstractModrinthClient,
|
||||
projectIds: ComputedRef<string[]>,
|
||||
) {
|
||||
return {
|
||||
queryKey: computed(() => ['project-download-modal', 'resolved-projects', projectIds.value]),
|
||||
queryFn: () => client.labrinth.projects_v2.getMultiple(projectIds.value),
|
||||
staleTime: STALE_TIME,
|
||||
}
|
||||
}
|
||||
|
||||
function createResolutionPreferences(
|
||||
version: Labrinth.Versions.v3.Version | null,
|
||||
currentPlatform: string | null,
|
||||
): Labrinth.Content.v3.ResolutionPreferences {
|
||||
return {
|
||||
game_versions: version?.game_versions || [],
|
||||
loaders: currentPlatform ? [currentPlatform] : version?.loaders || [],
|
||||
}
|
||||
}
|
||||
|
||||
function visibleDependencies(resolution?: Labrinth.Content.v3.ResolveContentPlan) {
|
||||
return [...(resolution?.dependencies || []), ...(resolution?.skipped || [])].filter(
|
||||
shouldShowDependency,
|
||||
)
|
||||
}
|
||||
|
||||
function getDependencyVersionIds(dependencies: ResolvedContent[]) {
|
||||
return sortedUnique(
|
||||
dependencies
|
||||
.filter((dependency) => !('reason' in dependency))
|
||||
.map((dependency) => dependency.version_id)
|
||||
.filter((versionId): versionId is string => !!versionId),
|
||||
)
|
||||
}
|
||||
|
||||
function getDependencyProjectIds(dependencies: ResolvedContent[]) {
|
||||
return sortedUnique(
|
||||
dependencies
|
||||
.map((dependency) => dependency.project_id)
|
||||
.filter((projectId): projectId is string => !!projectId),
|
||||
)
|
||||
}
|
||||
|
||||
function primaryFileForVersion(version?: Labrinth.Versions.v3.Version) {
|
||||
return version?.files?.find((file) => file.primary) || version?.files?.[0]
|
||||
}
|
||||
|
||||
function shouldShowDependency(dependency: ResolvedContent) {
|
||||
return !(
|
||||
'reason' in dependency && ['duplicate_project', 'quilt_fabric_api'].includes(dependency.reason)
|
||||
)
|
||||
}
|
||||
|
||||
function hasSkippedDuplicateDependency(resolution?: Labrinth.Content.v3.ResolveContentPlan) {
|
||||
return (resolution?.skipped || []).some((dependency) => dependency.reason === 'duplicate_project')
|
||||
}
|
||||
|
||||
function resolveContentType(projectType: DisplayProjectType): Labrinth.Content.v3.ContentType {
|
||||
return ['mod', 'plugin', 'datapack', 'resourcepack', 'shader', 'modpack'].includes(projectType)
|
||||
? (projectType as Labrinth.Content.v3.ContentType)
|
||||
: 'mod'
|
||||
}
|
||||
|
||||
function additionalFileKey(file: Labrinth.Versions.v3.VersionFile) {
|
||||
return file.hashes?.sha1 ?? file.filename
|
||||
}
|
||||
|
||||
function dedupeDependencyRows(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenDependencies = new Set<string>(),
|
||||
): DownloadDependencyRow[] {
|
||||
return rows.flatMap((row) => {
|
||||
const identity = dependencyRowIdentity(row)
|
||||
if (seenDependencies.has(identity)) return []
|
||||
|
||||
seenDependencies.add(identity)
|
||||
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
dependencies: dedupeDependencyRows(row.dependencies, seenDependencies),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function dependencyRowIdentity(row: DownloadDependencyRow) {
|
||||
return row.projectHref ?? row.downloadHref ?? row.key
|
||||
}
|
||||
|
||||
function hasDuplicateDependencyRows(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenDependencies = new Set<string>(),
|
||||
): boolean {
|
||||
for (const row of rows) {
|
||||
const rowId = dependencyRowIdentity(row)
|
||||
if (seenDependencies.has(rowId)) return true
|
||||
seenDependencies.add(rowId)
|
||||
if (hasDuplicateDependencyRows(row.dependencies, seenDependencies)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function hasAdditionalFileRows(rows: DownloadDependencyRow[]): boolean {
|
||||
for (const row of rows) {
|
||||
if (row.isAdditionalFile) return true
|
||||
if (hasAdditionalFileRows(row.dependencies)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function collectDownloadableDependencyFiles(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenHrefs = new Set<string>(),
|
||||
): DownloadableDependencyFile[] {
|
||||
const files: DownloadableDependencyFile[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.downloadHref && !seenHrefs.has(row.downloadHref)) {
|
||||
seenHrefs.add(row.downloadHref)
|
||||
files.push({
|
||||
href: row.downloadHref,
|
||||
filename: row.filename || filenameFromUrl(row.downloadHref),
|
||||
name: row.name,
|
||||
})
|
||||
}
|
||||
|
||||
files.push(...collectDownloadableDependencyFiles(row.dependencies, seenHrefs))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function filenameFromUrl(url: string) {
|
||||
try {
|
||||
const filename = new URL(url).pathname.split('/').pop()
|
||||
return filename ? decodeURIComponent(filename) : 'dependency.jar'
|
||||
} catch {
|
||||
return 'dependency.jar'
|
||||
}
|
||||
}
|
||||
|
||||
function sortedUnique(values: string[]) {
|
||||
return [...new Set(values)].sort()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
anyCompatibleDependency: {
|
||||
id: 'project.download.dependency-any-compatible',
|
||||
defaultMessage: 'Any compatible',
|
||||
},
|
||||
alreadyInstalledDependency: {
|
||||
id: 'project.download.dependency-already-installed',
|
||||
defaultMessage: 'This dependency is already installed',
|
||||
},
|
||||
conflictingDependency: {
|
||||
id: 'project.download.dependency-conflicting',
|
||||
defaultMessage: 'This dependency conflicts with another dependency',
|
||||
},
|
||||
duplicateDependency: {
|
||||
id: 'project.download.dependency-duplicate',
|
||||
defaultMessage: 'This dependency is already included',
|
||||
},
|
||||
missingDependencyVersion: {
|
||||
id: 'project.download.dependency-missing-version',
|
||||
defaultMessage: 'This dependency version is unavailable',
|
||||
},
|
||||
noCompatibleDependency: {
|
||||
id: 'project.download.dependency-no-compatible-version',
|
||||
defaultMessage: 'No compatible version is available for this dependency',
|
||||
},
|
||||
quiltFabricApiDependency: {
|
||||
id: 'project.download.dependency-quilt-fabric-api',
|
||||
defaultMessage: 'Fabric API is skipped for Quilt',
|
||||
},
|
||||
unavailableDependency: {
|
||||
id: 'project.download.dependency-unavailable',
|
||||
defaultMessage: 'This dependency cannot be downloaded',
|
||||
},
|
||||
unavailableFile: {
|
||||
id: 'project.download.file-unavailable',
|
||||
defaultMessage: 'This file cannot be downloaded',
|
||||
},
|
||||
requiredResourcePackShort: {
|
||||
id: 'project.download.required-resource-pack-short',
|
||||
defaultMessage: 'Resource pack',
|
||||
},
|
||||
})
|
||||
@@ -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" />
|
||||
@@ -19,6 +19,7 @@
|
||||
:project="project"
|
||||
:versions="versions"
|
||||
:dependency-download-files="dependencyDownloadFiles"
|
||||
:download-data-loaded="downloadRowsLoaded"
|
||||
:download-reason="downloadReason"
|
||||
:initial-game-version="initialGameVersion"
|
||||
:initial-platform="initialPlatform"
|
||||
@@ -27,20 +28,11 @@
|
||||
: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">
|
||||
<DownloadDependencies
|
||||
:project="project"
|
||||
:selected-version="selectedVersion"
|
||||
:current-game-version="currentGameVersion"
|
||||
:current-platform="currentPlatform"
|
||||
:download-reason="downloadReason"
|
||||
:additional-files="additionalFiles"
|
||||
@update:downloadable-files="dependencyDownloadFiles = $event"
|
||||
@download="onDownload"
|
||||
/>
|
||||
<DownloadDependencies @download="onDownload" />
|
||||
</div>
|
||||
<ServersPromo
|
||||
v-if="flags.showProjectPageDownloadModalServersPromo"
|
||||
@@ -54,16 +46,61 @@
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showDependencyDownloadActions" #actions>
|
||||
<div class="flex flex-wrap justify-end gap-2 p-2">
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="!shadow-none"
|
||||
:disabled="!!downloadingActionType || !dependencyDownloadFilesLoaded"
|
||||
@click="downloadSelectedVersionZip"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="downloadingActionType === 'zip'"
|
||||
aria-hidden="true"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<DownloadIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.downloadAsZip) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<JoinedButtons
|
||||
v-if="hasRecommendedDownloadFiles"
|
||||
color="brand"
|
||||
:actions="downloadWithRecommendedActions"
|
||||
:disabled="!!downloadingActionType || !dependencyDownloadFilesLoaded"
|
||||
/>
|
||||
<ButtonStyled v-else color="brand">
|
||||
<button
|
||||
class="!shadow-none"
|
||||
:disabled="!!downloadingActionType || !dependencyDownloadFilesLoaded"
|
||||
@click="downloadFilesWithDependencies"
|
||||
>
|
||||
<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,
|
||||
type JoinedButtonAction,
|
||||
JoinedButtons,
|
||||
NewModal,
|
||||
ServersPromo,
|
||||
truncatedTooltip,
|
||||
@@ -71,13 +108,16 @@ import {
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
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 { provideDownloadModalProvider } from './download-modal-provider'
|
||||
import DownloadDependencies from './DownloadDependencies.vue'
|
||||
import DownloadProject from './DownloadProject.vue'
|
||||
import InstallWithModrinthApp from './InstallWithModrinthApp.vue'
|
||||
@@ -99,6 +139,12 @@ type DownloadableFile = {
|
||||
filename: string
|
||||
}
|
||||
|
||||
type DownloadedFile = DownloadableFile & {
|
||||
blob: Blob
|
||||
}
|
||||
|
||||
type DownloadActionType = 'zip' | 'dependencies' | 'recommended'
|
||||
|
||||
type NewModalRef = {
|
||||
show: (event?: MouseEvent) => void
|
||||
hide: () => void
|
||||
@@ -138,19 +184,31 @@ const route = useRoute()
|
||||
const flags = useFeatureFlags()
|
||||
const tags = useGeneratedState()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
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 modalOpening = ref(false)
|
||||
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 dependencyDownloadFiles = ref<DownloadableFile[]>([])
|
||||
const pendingRouteSelection = ref({
|
||||
gameVersion: getStringQueryValue(route.query.version),
|
||||
platform: getStringQueryValue(route.query.loader),
|
||||
})
|
||||
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
|
||||
let modalShowRequestId = 0
|
||||
let unmounted = false
|
||||
|
||||
const routeProjectId = computed(() => showProjectId.value ?? props.projectId ?? null)
|
||||
|
||||
@@ -183,11 +241,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!, {
|
||||
@@ -198,24 +252,9 @@ const {
|
||||
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 versions = computed<Labrinth.Versions.v3.Version[]>(() =>
|
||||
normalizeVersionsForDownload(versionsV3.value ?? []),
|
||||
)
|
||||
|
||||
const initialGameVersion = computed(() => {
|
||||
const version = route.query.version
|
||||
@@ -238,6 +277,88 @@ const additionalFiles = computed(() => {
|
||||
return selectedVersion.value.files.filter((file) => file !== selectedPrimaryFile.value)
|
||||
})
|
||||
|
||||
const hasRequiredResourcePackAdditionalFile = computed(() =>
|
||||
additionalFiles.value.some((file) => file.file_type === 'required-resource-pack'),
|
||||
)
|
||||
|
||||
const downloadModalProvider = provideDownloadModalProvider({
|
||||
project,
|
||||
selectedVersion,
|
||||
selectedPrimaryFile,
|
||||
currentGameVersion,
|
||||
currentPlatform,
|
||||
downloadReason: computed(() => props.downloadReason),
|
||||
additionalFiles,
|
||||
})
|
||||
const dependencyDownloadFiles = downloadModalProvider.downloadableDependencyFiles
|
||||
const dependencyDownloadFilesLoaded = downloadModalProvider.downloadableDependencyFilesLoaded
|
||||
const downloadRowsLoaded = downloadModalProvider.downloadRowsLoaded
|
||||
|
||||
const selectedVersionDownloadFiles = computed<DownloadableFile[]>(() => {
|
||||
if (!selectedVersion.value) return []
|
||||
|
||||
return selectedVersion.value.files
|
||||
.filter(
|
||||
(file) => file === selectedPrimaryFile.value || file.file_type === 'required-resource-pack',
|
||||
)
|
||||
.map((file) => ({
|
||||
href: createProjectDownloadUrl(file.url, {
|
||||
reason: props.downloadReason,
|
||||
gameVersion: currentGameVersion.value ?? undefined,
|
||||
loader: currentPlatform.value ?? undefined,
|
||||
}),
|
||||
filename: file.filename,
|
||||
}))
|
||||
})
|
||||
|
||||
const recommendedDownloadFiles = computed<DownloadableFile[]>(() => {
|
||||
if (project.value?.project_type !== 'datapack' || !selectedVersion.value) return []
|
||||
|
||||
return selectedVersion.value.files
|
||||
.filter(
|
||||
(file) => file !== selectedPrimaryFile.value && file.file_type === 'optional-resource-pack',
|
||||
)
|
||||
.map((file) => ({
|
||||
href: createProjectDownloadUrl(file.url, {
|
||||
reason: props.downloadReason,
|
||||
gameVersion: currentGameVersion.value ?? undefined,
|
||||
loader: currentPlatform.value ?? undefined,
|
||||
}),
|
||||
filename: file.filename,
|
||||
}))
|
||||
})
|
||||
|
||||
const hasRecommendedDownloadFiles = computed(() => recommendedDownloadFiles.value.length > 0)
|
||||
|
||||
const showDependencyDownloadActions = computed(
|
||||
() =>
|
||||
selectedVersionDownloadFiles.value.length > 0 &&
|
||||
(dependencyDownloadFiles.value.length > 0 ||
|
||||
hasRequiredResourcePackAdditionalFile.value ||
|
||||
hasRecommendedDownloadFiles.value),
|
||||
)
|
||||
|
||||
const downloadWithRecommendedActions = computed<JoinedButtonAction[]>(() => [
|
||||
{
|
||||
id: 'download-with-dependencies',
|
||||
label: formatMessage(messages.downloadWithDependencies),
|
||||
icon: downloadingActionType.value === 'dependencies' ? SpinnerIcon : DownloadIcon,
|
||||
action: () => void downloadFilesWithDependencies(),
|
||||
},
|
||||
{
|
||||
id: 'download-with-recommended',
|
||||
label: formatMessage(messages.downloadWithRecommended),
|
||||
icon: DownloadIcon,
|
||||
action: () => void downloadFilesWithRecommended(),
|
||||
},
|
||||
{
|
||||
id: 'download-with-recommended-zip',
|
||||
label: formatMessage(messages.downloadWithRecommendedAsZip),
|
||||
icon: DownloadIcon,
|
||||
action: () => void downloadSelectedVersionZip(),
|
||||
},
|
||||
])
|
||||
|
||||
watch(projectV2Error, (error) => {
|
||||
if (error) {
|
||||
debug('project query failed', error)
|
||||
@@ -249,6 +370,30 @@ 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',
|
||||
},
|
||||
downloadWithRecommended: {
|
||||
id: 'project.download.download-with-recommended',
|
||||
defaultMessage: 'Download with recommended',
|
||||
},
|
||||
downloadWithRecommendedAsZip: {
|
||||
id: 'project.download.download-with-recommended-as-zip',
|
||||
defaultMessage: 'Download with recommended as .zip',
|
||||
},
|
||||
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 +423,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 +455,25 @@ 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) {
|
||||
projectDownloadSelection.value = selection
|
||||
pendingRouteSelection.value = {
|
||||
gameVersion: selection.currentGameVersion,
|
||||
platform: selection.currentPlatform,
|
||||
}
|
||||
}
|
||||
|
||||
function onShow() {
|
||||
clearCloseStateResetTimeout()
|
||||
modalOpen.value = true
|
||||
@@ -337,22 +502,37 @@ 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,
|
||||
if (!modal.value || modalOpening.value || modalOpen.value) return
|
||||
const showRequestId = ++modalShowRequestId
|
||||
modalOpening.value = true
|
||||
|
||||
try {
|
||||
await waitForCloseStateReset()
|
||||
if (!isActiveShowRequest(showRequestId)) return
|
||||
showOptions.value = {
|
||||
...getDefaultShowOptions(),
|
||||
...options,
|
||||
}
|
||||
showProjectId.value = showOptions.value.projectId ?? null
|
||||
await nextTick()
|
||||
if (!isActiveShowRequest(showRequestId)) return
|
||||
if (!(await loadProjectForModal(!!showOptions.value.projectId))) return
|
||||
if (!isActiveShowRequest(showRequestId)) return
|
||||
resetDownloadState()
|
||||
await preloadRouteSelectedDownload()
|
||||
if (!isActiveShowRequest(showRequestId)) return
|
||||
modalOpen.value = true
|
||||
modal.value.show(event)
|
||||
} finally {
|
||||
if (modalShowRequestId === showRequestId) {
|
||||
modalOpening.value = false
|
||||
}
|
||||
}
|
||||
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() {
|
||||
modalShowRequestId += 1
|
||||
modalOpening.value = false
|
||||
if (!modal.value || !modalOpen.value) return
|
||||
modal.value?.hide()
|
||||
}
|
||||
@@ -361,6 +541,199 @@ function onDownload() {
|
||||
emit('download')
|
||||
}
|
||||
|
||||
async function downloadSelectedVersionZip() {
|
||||
if (downloadingActionType.value || !dependencyDownloadFilesLoaded.value) return
|
||||
|
||||
downloadingActionType.value = 'zip'
|
||||
const files = dedupeDownloadFiles([
|
||||
...selectedVersionDownloadFiles.value,
|
||||
...dependencyDownloadFiles.value,
|
||||
...recommendedDownloadFiles.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 downloadFilesWithDependencies() {
|
||||
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 downloadFilesWithRecommended() {
|
||||
if (downloadingActionType.value || !dependencyDownloadFilesLoaded.value) return
|
||||
|
||||
downloadingActionType.value = 'recommended'
|
||||
|
||||
try {
|
||||
const files = dedupeDownloadFiles([
|
||||
...selectedVersionDownloadFiles.value,
|
||||
...dependencyDownloadFiles.value,
|
||||
...recommendedDownloadFiles.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,
|
||||
)}-EXTRACT_ME.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 +751,19 @@ function getDefaultShowOptions(): ResolvedProjectDownloadModalShowOptions {
|
||||
}
|
||||
}
|
||||
|
||||
function getStringQueryValue(value: unknown) {
|
||||
return typeof value === 'string' ? value : null
|
||||
}
|
||||
|
||||
function shouldPreloadRouteSelectedDownload() {
|
||||
return (
|
||||
props.useRouteHash &&
|
||||
!showOptions.value.projectId &&
|
||||
!!getStringQueryValue(route.query.version) &&
|
||||
!!getStringQueryValue(route.query.loader)
|
||||
)
|
||||
}
|
||||
|
||||
function clearCloseStateResetTimeout() {
|
||||
if (!closeStateResetTimeout) return
|
||||
clearTimeout(closeStateResetTimeout)
|
||||
@@ -386,7 +772,11 @@ function clearCloseStateResetTimeout() {
|
||||
|
||||
async function waitForCloseStateReset() {
|
||||
if (!closeStateResetTimeout) return
|
||||
await new Promise((resolve) => setTimeout(resolve, MODAL_CLOSE_STATE_RESET_MS))
|
||||
await delay(MODAL_CLOSE_STATE_RESET_MS)
|
||||
}
|
||||
|
||||
async function delay(ms: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function loadProjectForModal(forceRefetch: boolean) {
|
||||
@@ -397,9 +787,127 @@ async function loadProjectForModal(forceRefetch: boolean) {
|
||||
return !!data
|
||||
}
|
||||
|
||||
async function loadVersionsForModal() {
|
||||
if (!resolvedProjectId.value) return null
|
||||
versionsEnabled.value = true
|
||||
if (versionsV3.value) return versions.value
|
||||
|
||||
const data = await queryClient.ensureQueryData({
|
||||
queryKey: ['project', resolvedProjectId.value, 'versions', 'v3'],
|
||||
queryFn: () =>
|
||||
client.labrinth.versions_v3.getProjectVersions(resolvedProjectId.value!, {
|
||||
include_changelog: false,
|
||||
apiVersion: 3,
|
||||
}),
|
||||
staleTime: STALE_TIME_LONG,
|
||||
})
|
||||
|
||||
return Array.isArray(data) ? normalizeVersionsForDownload(data) : null
|
||||
}
|
||||
|
||||
async function preloadRouteSelectedDownload() {
|
||||
if (!shouldPreloadRouteSelectedDownload()) return
|
||||
|
||||
try {
|
||||
const routeVersions = await loadVersionsForModal()
|
||||
if (!routeVersions) return
|
||||
|
||||
const selection = getRouteSelectedDownloadSelection(routeVersions)
|
||||
if (!selection) return
|
||||
|
||||
await preloadDependenciesForSelection(selection)
|
||||
} catch (error) {
|
||||
debug('failed to preload selected route download', error)
|
||||
}
|
||||
}
|
||||
|
||||
function getRouteSelectedDownloadSelection(
|
||||
versionList: Labrinth.Versions.v3.Version[] = versions.value,
|
||||
): ProjectDownloadSelection | null {
|
||||
const gameVersion = initialGameVersion.value
|
||||
const platform = initialPlatform.value
|
||||
const version = getSelectedRouteVersion(gameVersion, platform, versionList)
|
||||
const primaryFile = version?.files?.find((file) => file.primary) || version?.files?.[0] || null
|
||||
|
||||
if (!gameVersion || !platform || !version || !primaryFile) return null
|
||||
|
||||
return {
|
||||
currentGameVersion: gameVersion,
|
||||
currentPlatform: platform,
|
||||
selectedVersion: version,
|
||||
selectedPrimaryFile: primaryFile,
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedRouteVersion(
|
||||
gameVersion: string | null,
|
||||
platform: string | null,
|
||||
versionList: Labrinth.Versions.v3.Version[],
|
||||
) {
|
||||
if (!gameVersion || !platform || !project.value) return null
|
||||
|
||||
const filteredVersions = versionList.filter((version) => {
|
||||
const matchesPlatform =
|
||||
project.value?.project_type === 'resourcepack' ||
|
||||
(!!platform && version.loaders.includes(platform))
|
||||
|
||||
return version.game_versions.includes(gameVersion) && matchesPlatform
|
||||
})
|
||||
|
||||
return (
|
||||
latestVersionByType(filteredVersions, 'release') ||
|
||||
latestVersionByType(filteredVersions, 'beta') ||
|
||||
latestVersionByType(filteredVersions, 'alpha') ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeVersionsForDownload(
|
||||
versionList: Labrinth.Versions.v3.Version[],
|
||||
): Labrinth.Versions.v3.Version[] {
|
||||
const isModpack =
|
||||
project.value?.actualProjectType === 'modpack' || project.value?.project_type === 'modpack'
|
||||
|
||||
return versionList.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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function latestVersionByType(
|
||||
versionList: Labrinth.Versions.v3.Version[],
|
||||
type: Labrinth.Versions.v3.VersionChannel,
|
||||
) {
|
||||
return versionList
|
||||
.filter((version) => version.version_type === type)
|
||||
.reduce<Labrinth.Versions.v3.Version | undefined>((latest, version) => {
|
||||
if (!latest || dayjs(version.date_published).isAfter(dayjs(latest.date_published))) {
|
||||
return version
|
||||
}
|
||||
|
||||
return latest
|
||||
}, undefined)
|
||||
}
|
||||
|
||||
async function preloadDependenciesForSelection(selection: ProjectDownloadSelection) {
|
||||
await downloadModalProvider.preloadDependenciesForSelection(selection)
|
||||
}
|
||||
|
||||
function isActiveShowRequest(showRequestId: number) {
|
||||
return !unmounted && modalShowRequestId === showRequestId && !!modal.value && !modalOpen.value
|
||||
}
|
||||
|
||||
function resetDownloadState() {
|
||||
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
|
||||
dependencyDownloadFiles.value = []
|
||||
downloadProjectResetKey.value += 1
|
||||
}
|
||||
|
||||
@@ -407,6 +915,7 @@ function openFromHash() {
|
||||
if (
|
||||
!props.useRouteHash ||
|
||||
!modal.value ||
|
||||
modalOpening.value ||
|
||||
modalOpen.value ||
|
||||
showProjectId.value ||
|
||||
route.hash !== '#download'
|
||||
@@ -437,11 +946,14 @@ watch(modal, openFromHash)
|
||||
watch(() => route.hash, openFromHash)
|
||||
watch(routeProjectId, () => {
|
||||
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
|
||||
dependencyDownloadFiles.value = []
|
||||
downloadProjectResetKey.value += 1
|
||||
})
|
||||
|
||||
onUnmounted(clearCloseStateResetTimeout)
|
||||
onUnmounted(() => {
|
||||
unmounted = true
|
||||
modalShowRequestId += 1
|
||||
clearCloseStateResetTimeout()
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user