feat: project download modal (#6602)

* refactor: componentize project download modal

* feat: implement new project download modal

* fix: button trigger placeholder text styles

* feat: dependency download button

* refactor: move project download modal into folder

* refactor: separate out download dependencies section

* refactor: split out download project and install with modrinth app sections

* feat+refactor: add nested dependencies route and move logic into related subcomponents

* feat: handle download project dependencies recursively and properly pass all game versions/platforms compatible on the selected version

* feat: if only dependency project is listed, find latest compatible dependency version to download in version page

* refactor: use ts and also have modal own get project/versions queries

* feat: implement opening project download modal in place in version page

* feat: implement incompatible options due to base project

* feat: auto select loader after selecting a game version, if only one loader is left compatible

* feat: add prop to stop route selection being persisted in url param

* pnpm prepr

* feat: update tooltip copy

* feat: polish some styles

* fix: fabric dependency showing when quilt is selected

* fix: tooltip hover area

* fix: when there are multiple compatible of same game version + loader, give latest

* feat: add link to version page

* feat: add truncated tooltip

* pnpm prepr

* feat: add warning icon for incompatible selection

* feat: polish loading state and tooltip

* feat: implement download all dependencies

* pnpm prepr

* fix: light theme card

* fix: additional files label

* refactor: move additional files into download dependencies section

* feat: move download all button in heading with compatible version

* fix color

* fix: dependencies not appearing when open modal second time

* fix: dependency lines when 3 nested

* feat: remove duplicated dependencies

* feat: add download count indicateor on download all button

* feat: add tooltip for downloading file with filename and size

* pnpm prepr

* feat: only show duplicate tooltip if there are duplicates
This commit is contained in:
Truman Gao
2026-07-04 21:45:34 +00:00
committed by GitHub
parent db830ef65c
commit 23cfeca91d
37 changed files with 2277 additions and 1338 deletions
+7 -613
View File
@@ -88,338 +88,12 @@
</div>
</div>
</div>
<NewModal
<ProjectDownloadModal
ref="downloadModal"
:on-show="
() => {
debug('on-show fired')
loadVersions()
navigateTo({ query: route.query, hash: '#download' }, { replace: true })
}
"
:on-hide="
() => {
navigateTo({ query: route.query, hash: '' }, { replace: true })
}
"
>
<template #title>
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
<div class="truncate text-lg font-extrabold text-contrast">
{{ formatMessage(messages.downloadTitle, { title: project.title }) }}
</div>
</template>
<template #default>
<div class="mx-auto flex max-w-[44rem] flex-col gap-4 md:w-[30rem]">
<div
v-if="
project.project_type !== 'plugin' ||
project.loaders.some((x) => !tags.loaderData.allPluginLoaders.includes(x))
"
class="modrinth-app-section contents"
>
<div class="mx-auto flex w-fit flex-col">
<ButtonStyled color="brand">
<a
class="w-fit"
:href="`modrinth://mod/${project.slug}`"
@click="() => installWithApp()"
>
<ModrinthIcon aria-hidden="true" />
{{ formatMessage(messages.installWithModrinthApp) }}
<ExternalIcon aria-hidden="true" />
</a>
</ButtonStyled>
<Accordion ref="getModrinthAppAccordion">
<nuxt-link
class="mt-2 flex justify-center text-brand-blue hover:underline"
to="/app"
>
{{ formatMessage(messages.dontHaveModrinthApp) }}
</nuxt-link>
</Accordion>
</div>
<div class="flex items-center gap-4 px-4">
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
<span class="flex-shrink-0 text-sm font-semibold text-secondary">
{{ formatMessage(commonMessages.orLabel) }}
</span>
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
</div>
</div>
<div class="mx-auto flex w-fit flex-col gap-2">
<ButtonStyled v-if="project.game_versions.length === 1">
<div class="disabled button-like">
<GameIcon aria-hidden="true" />
{{
currentGameVersion
? formatMessage(messages.gameVersionLabel, { version: currentGameVersion })
: formatMessage(messages.gameVersionError)
}}
<InfoIcon
v-tooltip="
formatMessage(messages.gameVersionTooltip, {
title: project.title,
version: currentGameVersion,
})
"
class="ml-auto size-5"
/>
</div>
</ButtonStyled>
<Accordion
v-else
ref="gameVersionAccordion"
class="accordion-with-bg"
@on-open="
() => {
if (platformAccordion) {
platformAccordion.close()
}
}
"
>
<template #title>
<GameIcon aria-hidden="true" />
{{
currentGameVersion
? formatMessage(messages.gameVersionLabel, { version: currentGameVersion })
: formatMessage(messages.selectGameVersion)
}}
</template>
<label for="game-versions-filtering" hidden>{{
formatMessage(messages.searchGameVersionsLabel)
}}</label>
<StyledInput
id="game-versions-filtering"
ref="gameVersionFilterInput"
v-model="versionFilter"
type="search"
autocomplete="off"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchGameVersions)"
wrapper-class="mb-2 w-full"
/>
<ScrollablePanel :class="project.game_versions.length > 4 ? 'h-[15rem]' : ''">
<ButtonStyled
v-for="gameVersion in project.game_versions
.filter(
(x) =>
(versionFilter && x.includes(versionFilter)) ||
(!versionFilter && (showAllVersions || isReleaseGameVersion(x))),
)
.slice()
.reverse()"
:key="gameVersion"
:color="currentGameVersion === gameVersion ? 'brand' : 'standard'"
>
<button
v-tooltip="
!possibleGameVersions.includes(gameVersion)
? formatMessage(messages.gameVersionUnsupportedTooltip, {
title: project.title,
gameVersion: gameVersion,
platform: currentPlatformText,
})
: null
"
:class="{
'looks-disabled !text-brand-red':
!possibleGameVersions.includes(gameVersion),
}"
@click="
() => {
userSelectedGameVersion = gameVersion
gameVersionAccordion.close()
if (!currentPlatform && platformAccordion) {
platformAccordion.open()
}
navigateTo(
{
query: {
...route.query,
...(userSelectedGameVersion && {
version: userSelectedGameVersion,
}),
...(userSelectedPlatform && {
loader: userSelectedPlatform,
}),
},
hash: route.hash,
},
{ replace: true },
)
}
"
>
{{ gameVersion }}
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
</button>
</ButtonStyled>
</ScrollablePanel>
<Checkbox
v-if="showVersionsCheckbox"
v-model="showAllVersions"
class="mx-1"
:label="formatMessage(messages.showAllVersions)"
:disabled="!!versionFilter"
/>
</Accordion>
<ButtonStyled
v-if="project.loaders.length === 1 && project.project_type !== 'resourcepack'"
>
<div class="disabled button-like">
<WrenchIcon aria-hidden="true" />
{{
currentPlatform
? formatMessage(messages.platformLabel, {
platform: currentPlatformText,
})
: formatMessage(messages.platformError)
}}
<InfoIcon
v-tooltip="
formatMessage(messages.platformTooltip, {
title: project.title,
platform: currentPlatformText,
})
"
class="ml-auto size-5"
/>
</div>
</ButtonStyled>
<Accordion
v-else-if="project.project_type !== 'resourcepack'"
ref="platformAccordion"
class="accordion-with-bg"
@on-open="
() => {
if (gameVersionAccordion) {
gameVersionAccordion.close()
}
}
"
>
<template #title>
<WrenchIcon aria-hidden="true" />
{{
currentPlatform
? formatMessage(messages.platformLabel, {
platform: currentPlatformText,
})
: formatMessage(messages.selectPlatform)
}}
</template>
<ScrollablePanel :class="project.loaders.length > 4 ? 'h-[15rem]' : ''">
<ButtonStyled
v-for="platform in project.loaders.slice().reverse()"
:key="platform"
:color="currentPlatform === platform ? 'brand' : 'standard'"
>
<button
v-tooltip="
!possiblePlatforms.includes(platform)
? formatMessage(messages.platformUnsupportedTooltip, {
title: project.title,
platform: currentPlatformText,
gameVersion: currentGameVersion,
})
: null
"
:class="{
'looks-disabled !text-brand-red': !possiblePlatforms.includes(platform),
}"
@click="
() => {
userSelectedPlatform = platform
platformAccordion.close()
if (!currentGameVersion && gameVersionAccordion) {
gameVersionAccordion.open()
}
navigateTo(
{
query: {
...route.query,
...(userSelectedGameVersion && {
version: userSelectedGameVersion,
}),
...(userSelectedPlatform && {
loader: userSelectedPlatform,
}),
},
hash: route.hash,
},
{ replace: true },
)
}
"
>
{{ formatMessage(getTagMessage(platform, 'loader')) }}
<CheckIcon v-if="userSelectedPlatform === platform" />
</button>
</ButtonStyled>
</ScrollablePanel>
</Accordion>
</div>
<AutomaticAccordion div class="flex flex-col gap-2">
<VersionSummary
v-if="filteredRelease"
:version="filteredRelease"
:decorate-download-url="decorateModalDownloadUrl"
@on-download="onDownload"
@on-navigate="onVersionNavigate"
/>
<VersionSummary
v-if="filteredBeta"
:version="filteredBeta"
:decorate-download-url="decorateModalDownloadUrl"
@on-download="onDownload"
@on-navigate="onVersionNavigate"
/>
<VersionSummary
v-if="filteredAlpha"
:version="filteredAlpha"
:decorate-download-url="decorateModalDownloadUrl"
@on-download="onDownload"
@on-navigate="onVersionNavigate"
/>
<p
v-if="
currentPlatform &&
currentGameVersion &&
!filteredRelease &&
!filteredBeta &&
!filteredAlpha &&
!versionsLoading &&
versions.length > 0
"
>
{{
formatMessage(messages.noVersionsAvailable, {
gameVersion: currentGameVersion,
platform: currentPlatformText,
})
}}
</p>
</AutomaticAccordion>
<ServersPromo
v-if="flags.showProjectPageDownloadModalServersPromo"
:link="`/hosting#plan`"
@close="
() => {
flags.showProjectPageDownloadModalServersPromo = false
saveFeatureFlags()
}
"
/>
</div>
</template>
</NewModal>
:project-id="routeProjectId"
:download-reason="downloadReason"
@download="triggerDownloadAnimation"
/>
<CollectionCreateModal ref="modal_collection" :project-ids="[project.id]" />
<div
class="new-page sidebar"
@@ -1048,27 +722,21 @@ import {
BookTextIcon,
CalendarIcon,
ChartIcon,
CheckIcon,
ChevronRightIcon,
ClipboardCopyIcon,
DownloadIcon,
ExternalIcon,
GameIcon,
HeartIcon,
InfoIcon,
ListIcon,
ModrinthIcon,
MoreVerticalIcon,
PlayIcon,
PlusIcon,
ReportIcon,
ScaleIcon,
ScanEyeIcon,
SearchIcon,
ServerPlusIcon,
SettingsIcon,
VersionIcon,
WrenchIcon,
XIcon,
} from '@modrinth/assets'
import {
@@ -1078,7 +746,6 @@ import {
Checkbox,
commonMessages,
defineMessages,
getTagMessage,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
@@ -1098,8 +765,6 @@ import {
ProjectSidebarServerInfo,
ProjectSidebarTags,
provideProjectPageContext,
ScrollablePanel,
ServersPromo,
StyledInput,
TagItem,
useDebugLogger,
@@ -1108,22 +773,19 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import VersionSummary from '@modrinth/ui/src/components/version/VersionSummary.vue'
import { capitalizeString, formatProjectType, renderString } from '@modrinth/utils'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useLocalStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import { nextTick, onScopeDispose, readonly, ref, useTemplateRef, watch, watchEffect } from 'vue'
import { onScopeDispose, readonly, ref, useTemplateRef, watch, watchEffect } from 'vue'
import { navigateTo } from '#app'
import Accordion from '~/components/ui/Accordion.vue'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import AutomaticAccordion from '~/components/ui/AutomaticAccordion.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import MessageBanner from '~/components/ui/MessageBanner.vue'
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
import ProjectDownloadModal from '~/components/ui/ProjectDownloadModal/index.vue'
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
@@ -1191,14 +853,6 @@ const downloadModal = ref()
const openInAppModal = ref()
const overTheTopDownloadAnimation = ref()
const userSelectedGameVersion = ref(null)
const userSelectedPlatform = ref(null)
const showAllVersions = ref(false)
const gameVersionFilterInput = ref()
const versionFilter = ref('')
const projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.value != null)
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
@@ -1206,80 +860,6 @@ const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
const baseId = useId()
const currentGameVersion = computed(() => {
if (!project.value) return null
return (
userSelectedGameVersion.value ||
(project.value.game_versions.length === 1 && project.value.game_versions[0])
)
})
const possibleGameVersions = computed(() => {
return versions.value
.filter((x) => !currentPlatform.value || x.loaders.includes(currentPlatform.value))
.flatMap((x) => x.game_versions)
})
const possiblePlatforms = computed(() => {
return versions.value
.filter((x) => !currentGameVersion.value || x.game_versions.includes(currentGameVersion.value))
.flatMap((x) => x.loaders)
})
const currentPlatform = computed(() => {
if (!project.value) return null
return (
userSelectedPlatform.value || (project.value.loaders.length === 1 && project.value.loaders[0])
)
})
const currentPlatformText = computed(() => {
if (!currentPlatform.value) return null
return formatMessage(getTagMessage(currentPlatform.value, 'loader'))
})
function decorateModalDownloadUrl(url) {
return createProjectDownloadUrl(url, {
reason: downloadReason.value,
gameVersion: currentGameVersion.value ?? undefined,
loader: currentPlatform.value ?? undefined,
})
}
const releaseVersions = computed(() => {
const set = new Set()
for (const gv of tags.value.gameVersions || []) {
if (gv?.version && gv.version_type === 'release') set.add(gv.version)
}
return set
})
const nonReleaseVersions = computed(() => {
const set = new Set()
for (const gv of tags.value.gameVersions || []) {
if (gv?.version && gv.version_type !== 'release') set.add(gv.version)
}
return set
})
function isReleaseGameVersion(ver) {
if (releaseVersions.value.has(ver)) return true
if (nonReleaseVersions.value.has(ver)) return false
return true
}
const showVersionsCheckbox = computed(() => {
const list = project.value?.game_versions || []
let hasRelease = false
let hasNonRelease = false
for (const v of list) {
if (isReleaseGameVersion(v)) hasRelease = true
else hasNonRelease = true
if (hasRelease && hasNonRelease) return true
}
return false
})
const serverProject = computed(() => ({
name: project.value.title,
slug: project.value.slug || project.value.id,
@@ -1295,16 +875,6 @@ function handlePlayServerProject() {
})
}
function installWithApp() {
setTimeout(() => {
getModrinthAppAccordion.value.open()
}, 1500)
}
const gameVersionAccordion = ref()
const platformAccordion = ref()
const getModrinthAppAccordion = ref()
const formatRelativeTime = useRelativeTime()
const detailsMessages = defineMessages({
@@ -1360,18 +930,10 @@ const messages = defineMessages({
id: 'project.description.title',
defaultMessage: 'Description',
},
dontHaveModrinthApp: {
id: 'project.download.no-app',
defaultMessage: "Don't have Modrinth App?",
},
dontShowAgain: {
id: 'project.actions.dont-show-again',
defaultMessage: "Don't show again",
},
downloadTitle: {
id: 'project.download.title',
defaultMessage: 'Download {title}',
},
downloadsStat: {
id: 'project.stats.downloads-label',
defaultMessage: '{count, plural, one {download} other {downloads}}',
@@ -1410,26 +972,6 @@ const messages = defineMessages({
id: 'project.gallery.title',
defaultMessage: 'Gallery',
},
gameVersionError: {
id: 'project.download.game-version-error',
defaultMessage: 'Error: no game versions found',
},
gameVersionLabel: {
id: 'project.download.game-version',
defaultMessage: 'Game version: {version}',
},
gameVersionTooltip: {
id: 'project.download.game-version-tooltip',
defaultMessage: '{title} is only available for {version}',
},
gameVersionUnsupportedTooltip: {
id: 'project.download.game-version-unsupported-tooltip',
defaultMessage: '{title} does not support {gameVersion} for {platform}',
},
installWithModrinthApp: {
id: 'project.download.install-with-app',
defaultMessage: 'Install with Modrinth App',
},
licenseErrorMessage: {
id: 'project.license.error',
defaultMessage: 'License text could not be retrieved.',
@@ -1454,30 +996,10 @@ const messages = defineMessages({
id: 'project.collections.none-found',
defaultMessage: 'No collections found.',
},
noVersionsAvailable: {
id: 'project.download.no-versions-available',
defaultMessage: 'No versions available for {gameVersion} and {platform}.',
},
pageNotFound: {
id: 'project.error.page-not-found',
defaultMessage: 'The page could not be found',
},
platformError: {
id: 'project.download.platform-error',
defaultMessage: 'Error: no platforms found',
},
platformLabel: {
id: 'project.download.platform',
defaultMessage: 'Platform: {platform}',
},
platformTooltip: {
id: 'project.download.platform-tooltip',
defaultMessage: '{title} is only available for {platform}',
},
platformUnsupportedTooltip: {
id: 'project.download.platform-unsupported-tooltip',
defaultMessage: '{title} does not support {platform} for {gameVersion}',
},
projectIconUpdated: {
id: 'project.notification.icon-updated.title',
defaultMessage: 'Project icon updated',
@@ -1506,22 +1028,6 @@ const messages = defineMessages({
id: 'project.actions.review-project',
defaultMessage: 'Review project',
},
searchGameVersions: {
id: 'project.download.search-game-versions',
defaultMessage: 'Search game versions...',
},
searchGameVersionsLabel: {
id: 'project.download.search-game-versions-label',
defaultMessage: 'Search game versions...',
},
selectGameVersion: {
id: 'project.download.select-game-version',
defaultMessage: 'Select game version',
},
selectPlatform: {
id: 'project.download.select-platform',
defaultMessage: 'Select platform',
},
serversPromoDescription: {
id: 'project.actions.servers-promo.description',
defaultMessage: 'Modrinth Hosting is the easiest way to play with your friends without hassle!',
@@ -1538,10 +1044,6 @@ const messages = defineMessages({
id: 'project.settings.title',
defaultMessage: 'Settings',
},
showAllVersions: {
id: 'project.download.show-all-versions',
defaultMessage: 'Show all versions',
},
versionsTab: {
id: 'project.versions.title',
defaultMessage: 'Versions',
@@ -1591,48 +1093,6 @@ async function getLicenseData(event) {
}
}
const filteredVersions = computed(() => {
const result = versions.value.filter(
(x) =>
x.game_versions?.includes(currentGameVersion.value) &&
(x.loaders?.includes(currentPlatform.value) || project.value.project_type === 'resourcepack'),
)
debug('filteredVersions', {
total: versions.value.length,
filtered: result.length,
currentGameVersion: currentGameVersion.value,
currentPlatform: currentPlatform.value,
versionsEnabled: versionsEnabled.value,
versionsLoading: versionsV3Loading.value,
sampleLoaders: versions.value.slice(0, 3).map((v) => v.loaders),
})
return result
})
const filteredRelease = computed(() => {
return filteredVersions.value.find((x) => x.version_type === 'release')
})
const filteredBeta = computed(() => {
return filteredVersions.value.find(
(x) =>
x.version_type === 'beta' &&
(!filteredRelease.value ||
dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))),
)
})
const filteredAlpha = computed(() => {
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))),
)
})
const displayCollectionsSearch = ref('')
const collections = computed(() =>
user.value && user.value.collections
@@ -2407,40 +1867,6 @@ if (!route.name.startsWith('type-project-settings')) {
const onUserCollectProject = useClientTry(userCollectProject)
const { version, loader } = route.query
if (
project.value &&
project.value.game_versions.length > 0 &&
project.value.game_versions.every((v) => !isReleaseGameVersion(v))
) {
showAllVersions.value = true
}
if (project.value && version !== undefined && project.value.game_versions.includes(version)) {
userSelectedGameVersion.value = version
}
if (project.value && loader !== undefined && project.value.loaders.includes(loader)) {
userSelectedPlatform.value = loader
}
if (route.hash === '#download' || version !== undefined || loader !== undefined) {
debug('eager loadVersions from setup', { hash: route.hash, version, loader })
loadVersions()
}
watch(downloadModal, (modal) => {
if (!modal) return
// route.hash returns everything in the hash string, including the # itself
if (route.hash === '#download') {
debug('hash #download watch fired, opening modal')
loadVersions()
modal.show()
}
})
watch(
[versionsV3, _versionsV3Error],
([data, error]) => {
@@ -2661,32 +2087,11 @@ watch(
{ immediate: true },
)
function closeDownloadModal(event) {
downloadModal.value.hide(event)
userSelectedPlatform.value = null
userSelectedGameVersion.value = null
showAllVersions.value = false
}
function triggerDownloadAnimation() {
overTheTopDownloadAnimation.value = true
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
}
function onDownload(event) {
triggerDownloadAnimation()
setTimeout(() => {
closeDownloadModal(event)
}, 400)
}
function onVersionNavigate(url) {
closeDownloadModal()
nextTick(() => {
navigateTo(url)
})
}
async function deleteVersion(id) {
if (!id) return
@@ -2842,11 +2247,6 @@ provideProjectPageContext({
display: none;
}
:deep(.accordion-with-bg) {
@apply rounded-2xl bg-bg p-2;
--scrollable-pane-bg: var(--color-bg);
}
.over-the-top-download-animation {
position: fixed;
z-index: 100;
@@ -2897,12 +2297,6 @@ provideProjectPageContext({
}
}
@media (hover: none) and (max-width: 767px) {
.modrinth-app-section {
display: none;
}
}
.servers-popup {
box-shadow:
0 0 12px 1px rgba(0, 175, 92, 0.6),
@@ -80,6 +80,13 @@
</div>
</div>
</NewModal>
<ProjectDownloadModal
ref="dependencyDownloadModal"
download-reason="dependency"
:use-route-hash="false"
:update-route-selection="false"
@download="emit('onDownload')"
/>
<div class="flex flex-col">
<nuxt-link
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
@@ -311,11 +318,25 @@
<template #dependencyActions="{ dependency }">
<ButtonStyled circular>
<nuxt-link
v-if="createDependencyLink(dependency)"
v-if="
createDependencyLink({
project: dependency.project,
version: dependency.version ?? getDependencyVersion(dependency.dependency),
})
"
v-tooltip="
formatMessage(dependency.version ? messages.viewVersion : messages.viewProject)
formatMessage(
(dependency.version ?? getDependencyVersion(dependency.dependency))
? messages.viewVersion
: messages.viewProject,
)
"
:to="
createDependencyLink({
project: dependency.project,
version: dependency.version ?? getDependencyVersion(dependency.dependency),
})
"
:to="createDependencyLink(dependency)"
target="_blank"
>
<ExternalIcon />
@@ -324,37 +345,40 @@
<ButtonStyled circular color="brand" color-fill="text">
<a
v-if="
dependency.version && dependency.dependency.dependency_type !== 'incompatible'
(dependency.version ?? getDependencyVersion(dependency.dependency)) &&
dependency.dependency.dependency_type !== 'incompatible'
"
v-tooltip="
dependencyVersionPrimaryFiles[dependency.version.id]
? dependencyVersionPrimaryFiles[dependency.version.id].filename +
' (' +
formatBytes(dependencyVersionPrimaryFiles[dependency.version.id].size) +
')'
: formatMessage(messages.noPrimaryFile)
"
:href="
createProjectDownloadUrl(
dependencyVersionPrimaryFiles[dependency.version.id].url,
{
reason: 'dependency',
},
getDependencyPrimaryFileTooltip(
dependency.version ?? getDependencyVersion(dependency.dependency),
)
"
:download="dependencyVersionPrimaryFiles[dependency.version.id].filename"
:disabled="dependencyVersionPrimaryFiles[dependency.version.id].url === undefined"
:href="
getDependencyDownloadUrl(
dependency.version ?? getDependencyVersion(dependency.dependency),
)
"
:download="
getDependencyPrimaryFile(
dependency.version ?? getDependencyVersion(dependency.dependency),
)?.filename
"
:disabled="
!getDependencyPrimaryFile(
dependency.version ?? getDependencyVersion(dependency.dependency),
)?.url
"
>
<DownloadIcon />
</a>
<a
<button
v-else-if="dependency.project"
v-tooltip="formatMessage(messages.downloadProject)"
:href="`/project/${dependency.project.id}#download`"
target="_blank"
:aria-label="formatMessage(messages.downloadProject)"
@click="openDependencyDownloadModal(dependency.project, $event)"
>
<DownloadIcon />
</a>
</button>
</ButtonStyled>
</template>
</VersionPage>
@@ -502,6 +526,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { onServerPrefetch } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import ProjectDownloadModal from '~/components/ui/ProjectDownloadModal/index.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { projectQueryOptions, STALE_TIME } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
@@ -658,6 +683,80 @@ watch(
const enrichment = computed(() => contextDependencies.value ?? undefined)
const projectOnlyDependencyProjectIds = computed(() => {
const projectIds = new Set<string>()
for (const dependency of version.value?.dependencies ?? []) {
if (
dependency.project_id &&
!dependency.version_id &&
dependency.dependency_type !== 'incompatible'
) {
projectIds.add(dependency.project_id)
}
}
return [...projectIds]
})
const dependencyResolutionLoaders = computed(() => {
if (!version.value) return []
if (version.value.loaders.includes('mrpack')) {
return (version.value.mrpack_loaders ?? []).filter((loader) => loader !== 'minecraft')
}
return version.value.loaders
})
const { data: projectOnlyDependencyVersions } = useQuery({
queryKey: computed(
() =>
[
'version-page',
'project-only-dependency-versions',
version.value?.id,
version.value?.game_versions ?? [],
dependencyResolutionLoaders.value,
projectOnlyDependencyProjectIds.value,
] as const,
),
queryFn: async () => {
const currentVersion = version.value!
const entries = await Promise.all(
projectOnlyDependencyProjectIds.value.map(async (projectId) => {
let versions: Labrinth.Versions.v2.Version[] = []
try {
versions = await client.labrinth.versions_v2.getProjectVersions(projectId, {
game_versions: currentVersion.game_versions,
loaders: dependencyResolutionLoaders.value,
include_changelog: false,
limit: 100,
})
} catch {
return [projectId, undefined] as const
}
return [
projectId,
getOnlyCompatibleDependencyVersion(
versions,
currentVersion.game_versions,
dependencyResolutionLoaders.value,
),
] as const
}),
)
const versionsByProjectId: Record<string, Labrinth.Versions.v2.Version> = {}
for (const [projectId, dependencyVersion] of entries) {
if (dependencyVersion) {
versionsByProjectId[projectId] = dependencyVersion
}
}
return versionsByProjectId
},
enabled: computed(() => !!version.value && projectOnlyDependencyProjectIds.value.length > 0),
staleTime: STALE_TIME,
})
const primaryFile = computed(
() => version.value?.files?.find((file) => file.primary) ?? version.value?.files?.[0],
)
@@ -690,6 +789,7 @@ useSeoMeta({
const editModal = useTemplateRef('editModal')
const confirmModal = useTemplateRef('confirmModal')
const packageModal = useTemplateRef('packageModal')
const dependencyDownloadModal = useTemplateRef('dependencyDownloadModal')
const packageLoaders = ref(['forge', 'fabric', 'quilt', 'neoforge'])
const packageLoaderOptions = [
@@ -712,6 +812,22 @@ function handleOpenEditVersionModal(versionId: string, projectId: string, stageI
editModal.value?.openEditVersionModal(versionId, projectId, stageId)
}
function openDependencyDownloadModal(
dependencyProject: Labrinth.Projects.v2.Project,
event: MouseEvent,
) {
const baseGameVersions = new Set(version.value?.game_versions ?? [])
const baseLoaders = new Set(dependencyResolutionLoaders.value)
dependencyDownloadModal.value?.show(event, {
projectId: dependencyProject.id,
incompatibleGameVersions: dependencyProject.game_versions.filter(
(gameVersion) => !baseGameVersions.has(gameVersion),
),
incompatibleLoaders: dependencyProject.loaders.filter((loader) => !baseLoaders.has(loader)),
})
}
const deleteVersionMutation = useMutation({
mutationFn: () => client.labrinth.versions_v3.deleteVersion(version.value!.id),
onSuccess: async () => {
@@ -823,6 +939,10 @@ const messages = defineMessages({
id: 'version.download.no-primary-file',
defaultMessage: 'Error: No primary file found',
},
downloadVersion: {
id: 'version.download.version',
defaultMessage: 'Download {version} ({size})',
},
edit: {
id: 'version.edit.button',
defaultMessage: 'Edit',
@@ -873,7 +993,7 @@ const messages = defineMessages({
},
downloadProject: {
id: 'version.download.download-dependency',
defaultMessage: 'Download dependency',
defaultMessage: 'Select a version to download',
},
requiredResourcePack: {
id: 'version.download.required-resource-pack',
@@ -951,13 +1071,20 @@ const copyFileHash = async (
file: Labrinth.Versions.v3.VersionFile,
method: Labrinth.Versions.v3.FileHashType,
) => {
await navigator.clipboard.writeText(file.hashes[method])
await copyToClipboard(file.hashes[method])
}
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text)
}
const dependencyVersionPrimaryFiles = computed(() => {
const versions = enrichment.value?.versions
const versions = [
...(enrichment.value?.versions ?? []),
...Object.values(projectOnlyDependencyVersions.value ?? {}),
]
const primaryFileMap: Record<string, Labrinth.Versions.v2.VersionFile> = {}
versions?.forEach((depVersion) => {
versions.forEach((depVersion) => {
const depPrimaryFile = depVersion.files.find((file) => file.primary) ?? depVersion.files[0]
primaryFileMap[depVersion.id] = depPrimaryFile
@@ -965,6 +1092,65 @@ const dependencyVersionPrimaryFiles = computed(() => {
return primaryFileMap
})
function getDependencyVersion(dependency: Labrinth.Versions.v3.Dependency) {
if (dependency.version_id || !dependency.project_id) {
return undefined
}
return projectOnlyDependencyVersions.value?.[dependency.project_id]
}
function getOnlyCompatibleDependencyVersion(
versions: Labrinth.Versions.v2.Version[],
gameVersions: string[],
loaders: string[],
) {
const compatibleVersions = versions
.filter(
(version) =>
version.game_versions.some((gameVersion) => gameVersions.includes(gameVersion)) &&
version.loaders.some((loader) => loaders.includes(loader)),
)
.slice()
.sort((a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime())
const targetKeys = new Set(
compatibleVersions.flatMap((version) =>
version.game_versions.flatMap((gameVersion) =>
gameVersions.includes(gameVersion)
? version.loaders
.filter((loader) => loaders.includes(loader))
.map((loader) => `${gameVersion}:${loader}`)
: [],
),
),
)
return targetKeys.size === 1 ? compatibleVersions[0] : undefined
}
function getDependencyPrimaryFile(version?: Labrinth.Versions.v2.Version) {
return version ? dependencyVersionPrimaryFiles.value[version.id] : undefined
}
function getDependencyPrimaryFileTooltip(version?: Labrinth.Versions.v2.Version) {
const dependencyPrimaryFile = getDependencyPrimaryFile(version)
return dependencyPrimaryFile
? formatMessage(messages.downloadVersion, {
version: version?.name || version?.version_number || dependencyPrimaryFile.filename,
size: formatBytes(dependencyPrimaryFile.size),
})
: formatMessage(messages.noPrimaryFile)
}
function getDependencyDownloadUrl(version?: Labrinth.Versions.v2.Version) {
const dependencyPrimaryFile = getDependencyPrimaryFile(version)
return dependencyPrimaryFile?.url
? createProjectDownloadUrl(dependencyPrimaryFile.url, {
reason: 'dependency',
})
: undefined
}
function createDependencyLink(context: {
project?: Labrinth.Projects.v2.Project
version?: Labrinth.Versions.v2.Version