bunch more fixes lol

This commit is contained in:
Prospector
2026-06-24 00:20:02 -07:00
parent 14c728c8e3
commit 623e01dbff
10 changed files with 315 additions and 97 deletions
@@ -433,7 +433,7 @@ function dismissInfoBanner() {
</nuxt-link> </nuxt-link>
</template> </template>
</IntlFormatted> </IntlFormatted>
<template #actions> <template v-if="false" #actions>
<div class="flex"> <div class="flex">
<ButtonStyled color="blue"> <ButtonStyled color="blue">
<a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a> <a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a>
@@ -82,6 +82,7 @@
<VersionPage <VersionPage
:version="version" :version="version"
:enrichment="enrichment" :enrichment="enrichment"
:enrichment-loading="dependenciesLoading"
:members="members" :members="members"
:user-link-creator="(user) => (moderator ? `/user/${user.id}` : undefined)" :user-link-creator="(user) => (moderator ? `/user/${user.id}` : undefined)"
:dependency-link-creator="createDependencyLink" :dependency-link-creator="createDependencyLink"
@@ -400,7 +401,7 @@
</pre </pre
> >
</template> </template>
<template v-else> <template v-else-if="showVersionSkeleton">
<div class="flex flex-col gap-4 pb-[30rem]"> <div class="flex flex-col gap-4 pb-[30rem]">
<div <div
class="mt-4 flex h-[8rem] w-full animate-pulse items-center justify-center rounded-2xl bg-surface-3" class="mt-4 flex h-[8rem] w-full animate-pulse items-center justify-center rounded-2xl bg-surface-3"
@@ -466,10 +467,12 @@ import {
} from '@modrinth/ui' } from '@modrinth/ui'
import { isStaff } from '@modrinth/utils' import { isStaff } from '@modrinth/utils'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { onServerPrefetch } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue' import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import { getSignInRouteObj } from '~/composables/auth.js' import { getSignInRouteObj } from '~/composables/auth.js'
import { STALE_TIME } from '~/composables/queries/project' import { projectQueryOptions, STALE_TIME } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import { createDataPackVersion } from '~/helpers/package.js' import { createDataPackVersion } from '~/helpers/package.js'
import { reportVersion } from '~/utils/report-helpers.ts' import { reportVersion } from '~/utils/report-helpers.ts'
@@ -499,6 +502,7 @@ const {
versionsLoading, versionsLoading,
loadVersions, loadVersions,
dependencies: contextDependencies, dependencies: contextDependencies,
dependenciesLoading,
loadDependencies, loadDependencies,
invalidate, invalidate,
cdnDownloadReason, cdnDownloadReason,
@@ -522,26 +526,31 @@ const signInRouteObj = computed(() => getSignInRouteObj(route))
const versionRouteParam = computed(() => route.params.version as string) const versionRouteParam = computed(() => route.params.version as string)
const isLatestRoute = computed(() => versionRouteParam.value === 'latest') const isLatestRoute = computed(() => versionRouteParam.value === 'latest')
function filterVersionsForLatestRoute(allVersions: Labrinth.Versions.v3.Version[]) {
let filtered = allVersions
const loaderFilter = route.query.loader
if (typeof loaderFilter === 'string') {
filtered = filtered.filter((x) => x.loaders.includes(loaderFilter))
}
const gameVersionFilter = route.query.version
if (typeof gameVersionFilter === 'string') {
filtered = filtered.filter((x) => x.game_versions.includes(gameVersionFilter))
}
return filtered
}
const latestVersionId = computed(() => { const latestVersionId = computed(() => {
if (!isLatestRoute.value) { if (!isLatestRoute.value) {
return null return null
} }
let allVersions = versions.value ?? [] const filtered = filterVersionsForLatestRoute(versions.value ?? [])
if (filtered.length === 0) return null
const loaderFilter = route.query.loader return filtered.reduce((a, b) => (a.date_published > b.date_published ? a : b)).id
if (typeof loaderFilter === 'string') {
allVersions = allVersions.filter((x) => x.loaders.includes(loaderFilter))
}
const gameVersionFilter = route.query.version
if (typeof gameVersionFilter === 'string') {
allVersions = allVersions.filter((x) => x.game_versions.includes(gameVersionFilter))
}
if (allVersions.length === 0) return null
return allVersions.reduce((a, b) => (a.date_published > b.date_published ? a : b)).id
}) })
const versionLookupKey = computed(() => const versionLookupKey = computed(() =>
@@ -552,6 +561,7 @@ const {
data: version, data: version,
refetch: refetchVersion, refetch: refetchVersion,
error: versionError, error: versionError,
isPending: versionPending,
} = useQuery({ } = useQuery({
queryKey: computed( queryKey: computed(
() => ['project', project.value.id, 'version', 'v3', versionLookupKey.value] as const, () => ['project', project.value.id, 'version', 'v3', versionLookupKey.value] as const,
@@ -562,6 +572,30 @@ const {
staleTime: STALE_TIME, staleTime: STALE_TIME,
}) })
const showVersionSkeleton = computed(() => import.meta.client && versionPending.value)
onServerPrefetch(async () => {
if (!project.value.id) return
let lookupKey = versionRouteParam.value
if (isLatestRoute.value) {
loadVersions()
const versionsData = await queryClient.ensureQueryData(
projectQueryOptions.versionsV3(project.value.id, client),
)
const filtered = filterVersionsForLatestRoute(versionsData ?? [])
if (filtered.length === 0) return
lookupKey = filtered.reduce((a, b) => (a.date_published > b.date_published ? a : b)).id
}
if (!lookupKey || lookupKey === 'latest') return
await queryClient.ensureQueryData(
versionQueryOptions.fromProject(project.value.id, lookupKey, client),
)
})
watch( watch(
versionError, versionError,
(error) => { (error) => {
@@ -18,6 +18,7 @@
<div class="group relative float-end ml-4"> <div class="group relative float-end ml-4">
<OverflowMenu <OverflowMenu
v-tooltip="formatMessage(messages.editIconButton)" v-tooltip="formatMessage(messages.editIconButton)"
:dropdown-id="`${baseId}-edit-icon`"
class="m-0 cursor-pointer appearance-none border-none bg-transparent p-0 transition-transform group-active:scale-95" class="m-0 cursor-pointer appearance-none border-none bg-transparent p-0 transition-transform group-active:scale-95"
:options="[ :options="[
{ {
@@ -138,7 +139,7 @@
</NewModal> </NewModal>
<NormalPage :sidebar="cosmetics.leftContentLayout ? 'left' : 'right'"> <NormalPage :sidebar="cosmetics.leftContentLayout ? 'left' : 'right'">
<template #header> <template #header>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-4">
<ClientOnly> <ClientOnly>
<nuxt-link <nuxt-link
v-if="returnLink" v-if="returnLink"
@@ -201,19 +202,32 @@
</span> </span>
</div> </div>
</div> </div>
<div class="col-span-2 flex gap-2 sm:col-span-1"> <div class="col-span-2 flex items-center gap-2 sm:col-span-1">
<template v-if="canEdit"> <template v-if="canEdit">
<ButtonStyled> <ButtonStyled size="large">
<button @click="openEditModal"> <button @click="openEditModal">
<EditIcon aria-hidden="true" /> <EditIcon aria-hidden="true" />
{{ formatMessage(commonMessages.editButton) }} {{ formatMessage(commonMessages.editButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled color="red" color-fill="text"> <ButtonStyled size="large" circular type="transparent">
<button @click="() => $refs.deleteModal.show()"> <OverflowMenu
<TrashIcon aria-hidden="true" /> :dropdown-id="`${baseId}-more-options`"
{{ formatMessage(commonMessages.deleteLabel) }} :options="[
</button> {
id: 'delete',
color: 'red',
action: () => deleteModal?.show(),
},
]"
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
>
<MoreVerticalIcon aria-hidden="true" />
<template #delete>
<TrashIcon aria-hidden="true" />
{{ formatMessage(commonMessages.deleteLabel) }}
</template>
</OverflowMenu>
</ButtonStyled> </ButtonStyled>
</template> </template>
</div> </div>
@@ -226,7 +240,12 @@
v-if="collection.description" v-if="collection.description"
:title="formatMessage(commonMessages.descriptionLabel)" :title="formatMessage(commonMessages.descriptionLabel)"
> >
<p class="m-0 break-words">{{ collection.description }}</p> <div
v-if="supportsMarkdown"
class="description-body"
v-html="renderString(collection.description)"
/>
<p v-else class="m-0 break-words">{{ collection.description }}</p>
</SidebarCard> </SidebarCard>
<SidebarCard <SidebarCard
v-if="collection.id !== 'following'" v-if="collection.id !== 'following'"
@@ -238,9 +257,7 @@
> >
<Avatar :src="creator.avatar_url" :alt="creator.username" size="32px" circle /> <Avatar :src="creator.avatar_url" :alt="creator.username" size="32px" circle />
<div class="flex flex-col"> <div class="flex flex-col">
<span <span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline">
class="grid w-full grid-cols-[1fr_auto] flex-nowrap items-center gap-1 group-hover:underline"
>
<span class="min-w-0 overflow-hidden truncate">{{ creator.username }}</span> <span class="min-w-0 overflow-hidden truncate">{{ creator.username }}</span>
</span> </span>
</div> </div>
@@ -373,6 +390,7 @@ import {
HeartMinusIcon, HeartMinusIcon,
LinkIcon, LinkIcon,
LockIcon, LockIcon,
MoreVerticalIcon,
SaveIcon, SaveIcon,
SpinnerIcon, SpinnerIcon,
TrashIcon, TrashIcon,
@@ -411,9 +429,10 @@ import {
useSavable, useSavable,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { isAdmin } from '@modrinth/utils' import { isAdmin, renderString } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query' import { useQuery, useQueryClient } from '@tanstack/vue-query'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { onServerPrefetch } from 'vue'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue' import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
@@ -431,6 +450,32 @@ const route = useNativeRoute()
const router = useRouter() const router = useRouter()
const auth = await useAuth() const auth = await useAuth()
const cosmetics = useCosmetics() const cosmetics = useCosmetics()
const queryClient = useQueryClient()
const baseId = useId()
async function fetchProjectsByIds(projectIds) {
const segmentSize = 800
const segments = []
for (let i = 0; i < projectIds.length; i += segmentSize) {
segments.push(projectIds.slice(i, i + segmentSize))
}
const results = await Promise.all(
segments.map((ids) => api.labrinth.projects_v2.getMultiple(ids)),
)
const projects = results.flat()
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
}
return projects
}
async function fetchFollowedProjects(userId) {
const projects = await api.labrinth.users_v2.getFollowedProjects(userId)
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
}
return projects
}
const messages = defineMessages({ const messages = defineMessages({
collectionDescription: { collectionDescription: {
@@ -595,6 +640,8 @@ const creator = computed(() =>
isFollowingCollection.value ? auth.value.user : fetchedCreator.value, isFollowingCollection.value ? auth.value.user : fetchedCreator.value,
) )
const supportsMarkdown = computed(() => creator.value?.id === '2REoufqX')
// Query for followed projects // Query for followed projects
const { const {
data: followedProjects, data: followedProjects,
@@ -602,13 +649,7 @@ const {
isFetching: followedProjectsIsFetching, isFetching: followedProjectsIsFetching,
} = useQuery({ } = useQuery({
queryKey: computed(() => ['user', auth.value.user?.id, 'follows']), queryKey: computed(() => ['user', auth.value.user?.id, 'follows']),
queryFn: async () => { queryFn: async () => fetchFollowedProjects(auth.value.user.id),
const projects = await api.labrinth.users_v2.getFollowedProjects(auth.value.user.id)
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
}
return projects
},
enabled: computed(() => isFollowingCollection.value && !!auth.value.user?.id), enabled: computed(() => isFollowingCollection.value && !!auth.value.user?.id),
placeholderData: [], placeholderData: [],
}) })
@@ -620,22 +661,7 @@ const {
isFetching: collectionProjectsIsFetching, isFetching: collectionProjectsIsFetching,
} = useQuery({ } = useQuery({
queryKey: computed(() => ['projects', collection.value?.projects]), queryKey: computed(() => ['projects', collection.value?.projects]),
queryFn: async () => { queryFn: () => fetchProjectsByIds(collection.value.projects),
const projectIds = collection.value.projects
const segmentSize = 800
const segments = []
for (let i = 0; i < projectIds.length; i += segmentSize) {
segments.push(projectIds.slice(i, i + segmentSize))
}
const results = await Promise.all(
segments.map((ids) => api.labrinth.projects_v2.getMultiple(ids)),
)
const projects = results.flat()
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
}
return projects
},
enabled: computed(() => !isFollowingCollection.value && !!collection.value?.projects?.length), enabled: computed(() => !isFollowingCollection.value && !!collection.value?.projects?.length),
placeholderData: [], placeholderData: [],
}) })
@@ -647,12 +673,48 @@ const projects = computed(() =>
// Loading state // Loading state
const isLoading = computed(() => { const isLoading = computed(() => {
if (!import.meta.client) return false
if (isFollowingCollection.value) { if (isFollowingCollection.value) {
return followedProjectsIsFetching.value return followedProjectsIsFetching.value
} }
return collectionIsPending.value || creatorIsPending.value || collectionProjectsIsFetching.value return collectionIsPending.value || creatorIsPending.value || collectionProjectsIsFetching.value
}) })
onServerPrefetch(async () => {
if (isFollowingCollection.value) {
const userId = auth.value.user?.id
if (!userId) return
await queryClient.ensureQueryData({
queryKey: ['user', userId, 'follows'],
queryFn: () => fetchFollowedProjects(userId),
})
return
}
if (!collectionId) return
const collectionData = await queryClient.ensureQueryData({
queryKey: ['collection', collectionId],
queryFn: () => api.labrinth.collections.get(collectionId),
})
if (collectionData?.user) {
await queryClient.ensureQueryData({
queryKey: ['user', collectionData.user],
queryFn: () => api.labrinth.users_v2.get(collectionData.user),
})
}
if (collectionData?.projects?.length) {
await queryClient.ensureQueryData({
queryKey: ['projects', collectionData.projects],
queryFn: () => fetchProjectsByIds(collectionData.projects),
})
}
})
watch( watch(
[collection, creator], [collection, creator],
([col, cre]) => { ([col, cre]) => {
@@ -716,6 +778,7 @@ const showUpdatedDate = computed(() => {
}) })
const editModal = ref(null) const editModal = ref(null)
const deleteModal = ref(null)
const iconInputRef = ref(null) const iconInputRef = ref(null)
const icon = ref(null) const icon = ref(null)
const deletedIcon = ref(false) const deletedIcon = ref(false)
@@ -881,4 +944,15 @@ function openEditModal(event) {
// Omorphia's dropdowns are harcoded in width, so we need to override that // Omorphia's dropdowns are harcoded in width, so we need to override that
width: 100% !important; width: 100% !important;
} }
:deep(.description-body) {
p {
margin: 0;
}
a {
color: var(--color-brand);
font-weight: 600;
}
}
</style> </style>
@@ -146,9 +146,8 @@
<div class="normal-page__sidebar"> <div class="normal-page__sidebar">
<AdPlaceholder v-if="!auth.user" /> <AdPlaceholder v-if="!auth.user" />
<div class="card flex-card"> <SidebarCard title="Members">
<h2>Members</h2> <div class="flex flex-col gap-3 font-semibold">
<div class="details-list">
<nuxt-link <nuxt-link
v-for="member in acceptedMembers" v-for="member in acceptedMembers"
:key="`member-${member?.user?.id}`" :key="`member-${member?.user?.id}`"
@@ -163,22 +162,20 @@
/> />
<div class="flex flex-col"> <div class="flex flex-col">
<span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline"> <span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline">
<span class="min-w-0 overflow-hidden truncate font-normal text-contrast">{{ <span class="min-w-0 overflow-hidden truncate">{{ member.user.username }}</span>
member.user.username
}}</span>
<CrownIcon <CrownIcon
v-if="member.is_owner" v-if="member.is_owner"
v-tooltip="'Organization owner'" v-tooltip="'Organization owner'"
class="text-brand-orange" class="text-brand-orange"
/> />
</span> </span>
<span class="text-sm font-normal"> <span class="text-sm font-normal text-secondary">
{{ member?.role ? member.role : 'Member' }} {{ member?.role ? member.role : 'Member' }}
</span> </span>
</div> </div>
</nuxt-link> </nuxt-link>
</div> </div>
</div> </SidebarCard>
</div> </div>
<div class="normal-page__content"> <div class="normal-page__content">
<div v-if="isInvited" class="universal-card information invited"> <div v-if="isInvited" class="universal-card information invited">
@@ -317,6 +314,7 @@ import {
PROJECT_DEP_MARKER_QUERY, PROJECT_DEP_MARKER_QUERY,
ProjectCard, ProjectCard,
ProjectCardList, ProjectCardList,
SidebarCard,
useCompactNumber, useCompactNumber,
useFormatNumber, useFormatNumber,
useVIntl, useVIntl,
@@ -774,4 +772,8 @@ async function copyPermalink() {
.popout-checkbox { .popout-checkbox {
padding: var(--gap-sm) var(--gap-md); padding: var(--gap-sm) var(--gap-md);
} }
.new-page {
column-gap: 1.5rem;
}
</style> </style>
+5 -1
View File
@@ -477,7 +477,7 @@
v-if="organizations?.length > 0" v-if="organizations?.length > 0"
class="mb-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4 pt-3" class="mb-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4 pt-3"
> >
<h2 class="m-0 mb-2 text-lg text-contrast"> <h2 class="m-0 mb-2 text-lg font-semibold text-contrast">
{{ formatMessage(messages.profileOrganizations) }} {{ formatMessage(messages.profileOrganizations) }}
</h2> </h2>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@@ -1040,4 +1040,8 @@ export default defineNuxtComponent({
} }
} }
} }
.new-page {
column-gap: 1.5rem;
}
</style> </style>
@@ -36,7 +36,7 @@ const { hierarchicalSidebarAvailable } = injectPageContext()
</template> </template>
<style scoped> <style scoped>
.ui-normal-page { .ui-normal-page {
@apply grid gap-6 mx-auto py-4; @apply grid gap-x-6 gap-y-4 mx-auto py-4;
width: min(calc(100% - 2rem), calc(80rem - 3rem)); width: min(calc(100% - 2rem), calc(80rem - 3rem));
grid-template: grid-template:
@@ -9,12 +9,13 @@ defineProps<{
</script> </script>
<template> <template>
<div <div
class="flex flex-col gap-3 p-4" class="flex flex-col gap-3 p-4 pt-3"
:class="{ :class="{
'card-shadow mb-4 last:mb-0 rounded-2xl bg-bg-raised': !hierarchicalSidebarAvailable, 'card-shadow mb-4 last:mb-0 rounded-2xl bg-surface-3 border border-solid border-surface-4':
!hierarchicalSidebarAvailable,
}" }"
> >
<span class="font-semibold">{{ title }}</span> <h2 class="text-lg font-semibold m-0">{{ title }}</h2>
<slot /> <slot />
</div> </div>
</template> </template>
@@ -10,10 +10,10 @@
> >
<Avatar :src="organization.icon_url" :alt="organization.name" size="32px" /> <Avatar :src="organization.icon_url" :alt="organization.name" size="32px" />
<div class="flex flex-col flex-nowrap justify-center"> <div class="flex flex-col flex-nowrap justify-center">
<span class="group-hover:underline font-normal text-contrast"> <span class="group-hover:underline font-medium">
{{ organization.name }} {{ organization.name }}
</span> </span>
<span class="text-sm font-normal flex items-center gap-1" <span class="text-sm font-normal text-secondary flex items-center gap-1"
><OrganizationIcon /> {{ formatMessage(messages.organization) }}</span ><OrganizationIcon /> {{ formatMessage(messages.organization) }}</span
> >
</div> </div>
@@ -30,9 +30,7 @@
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="32px" circle /> <Avatar :src="member.user.avatar_url" :alt="member.user.username" size="32px" circle />
<div class="flex flex-col"> <div class="flex flex-col">
<span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline"> <span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline">
<span class="min-w-0 overflow-hidden font-normal text-contrast truncate">{{ <span class="min-w-0 overflow-hidden truncate">{{ member.user.username }}</span>
member.user.username
}}</span>
<CrownIcon <CrownIcon
v-if="member.is_owner" v-if="member.is_owner"
v-tooltip="formatMessage(messages.owner)" v-tooltip="formatMessage(messages.owner)"
@@ -40,7 +38,7 @@
/> />
<ExternalIcon v-if="linkTarget === '_blank'" /> <ExternalIcon v-if="linkTarget === '_blank'" />
</span> </span>
<span class="text-sm font-normal">{{ member.role }}</span> <span class="text-sm font-normal text-secondary">{{ member.role }}</span>
</div> </div>
</AutoLink> </AutoLink>
</div> </div>
@@ -483,7 +483,7 @@ const earnedBadges = computed(() => {
<template> <template>
<div v-if="earnedBadges.length > 0 || !!downloadsBadge" class="flex flex-col"> <div v-if="earnedBadges.length > 0 || !!downloadsBadge" class="flex flex-col">
<h2 class="text-lg text-contrast m-0 mb-2"> <h2 class="text-lg font-semibold text-contrast m-0 mb-2">
{{ formatMessage(messages.title) }} {{ formatMessage(messages.title) }}
</h2> </h2>
<div class="grid grid-cols-[repeat(auto-fill,minmax(64px,1fr))] gap-2"> <div class="grid grid-cols-[repeat(auto-fill,minmax(64px,1fr))] gap-2">
@@ -1,7 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client' import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, FileIcon, SearchIcon } from '@modrinth/assets' import { DownloadIcon, ExternalIcon, FileIcon, SearchIcon } from '@modrinth/assets'
import { capitalizeString, renderHighlightedString } from '@modrinth/utils' import {
capitalizeString,
formatVersionsForDisplay,
type GameVersionTag,
renderHighlightedString,
} from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query' import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@@ -11,6 +16,7 @@ import { useCompactNumber, useFormatNumber } from '#ui/composables/format-number
import { useRelativeTime } from '#ui/composables/how-ago.ts' import { useRelativeTime } from '#ui/composables/how-ago.ts'
import { defineMessage, defineMessages, useVIntl } from '#ui/composables/i18n.ts' import { defineMessage, defineMessages, useVIntl } from '#ui/composables/i18n.ts'
import { injectModrinthClient } from '#ui/providers/api-client.ts' import { injectModrinthClient } from '#ui/providers/api-client.ts'
import { injectTags } from '#ui/providers/tags.ts'
import { import {
commonMessages, commonMessages,
fileTypeMessages, fileTypeMessages,
@@ -41,12 +47,23 @@ const { formatCompactNumber } = useCompactNumber()
const props = defineProps<{ const props = defineProps<{
version: Labrinth.Versions.v3.Version version: Labrinth.Versions.v3.Version
enrichment?: Labrinth.Projects.v2.DependencyInfo enrichment?: Labrinth.Projects.v2.DependencyInfo
enrichmentLoading?: boolean
dependencyLinkCreator: (context: DependencyContext) => string | undefined dependencyLinkCreator: (context: DependencyContext) => string | undefined
members?: Labrinth.Projects.v3.TeamMember[] members?: Labrinth.Projects.v3.TeamMember[]
userLinkCreator?: (user: Labrinth.Users.v3.User) => string | undefined userLinkCreator?: (user: Labrinth.Users.v3.User) => string | undefined
}>() }>()
const api = injectModrinthClient() const api = injectModrinthClient()
const tags = injectTags(null)
const gameVersionsToDisplay = computed(() =>
tags?.gameVersions.value?.length
? formatVersionsForDisplay(
props.version.game_versions,
tags.gameVersions.value as GameVersionTag[],
)
: props.version.game_versions,
)
const versionNumber = computed(() => props.version.version_number) const versionNumber = computed(() => props.version.version_number)
const versionSubtitle = computed(() => props.version.name) const versionSubtitle = computed(() => props.version.name)
@@ -101,22 +118,82 @@ const optionalContent = computed(() =>
const incompatibleContent = computed(() => const incompatibleContent = computed(() =>
dependencies.value.filter((dep) => dep.dependency.dependency_type === 'incompatible'), dependencies.value.filter((dep) => dep.dependency.dependency_type === 'incompatible'),
) )
const includedContent = computed(() => const embeddedDependencies = computed(() =>
dependencies.value dependencies.value.filter((context) => context.dependency.dependency_type === 'embedded'),
.filter((context) => context.dependency.dependency_type === 'embedded')
.map((context) => ({
icon_url: context.project
? context.project.icon_url
: context.dependency.attribution?.icon_url,
name: context.project ? context.project.title : context.dependency.file_name,
version: context.version ? context.version.version_number : undefined,
link: context.project
? props.dependencyLinkCreator(context)
: context.dependency.attribution?.link,
hasProject: !!context.project,
})),
) )
const includedContentLoading = computed(
() =>
!!props.enrichmentLoading &&
embeddedDependencies.value.some((context) => context.dependency.project_id && !context.project),
)
function getEmbeddedDependencyId(context: DependencyContext) {
return (
context.dependency.project_id ??
context.dependency.file_name ??
context.dependency.version_id ??
'included'
)
}
type IncludedContentLoadingRow = { loading: true; id: string }
type IncludedContentRow = {
id: string
icon_url?: string
name?: string
version?: string
link?: string
hasProject: boolean
}
function isIncludedContentLoadingRow(
row: IncludedContentLoadingRow | IncludedContentRow,
): row is IncludedContentLoadingRow {
return 'loading' in row
}
const includedContent = computed((): (IncludedContentLoadingRow | IncludedContentRow)[] => {
if (includedContentLoading.value) {
return embeddedDependencies.value.map((context) => ({
loading: true as const,
id: getEmbeddedDependencyId(context),
}))
}
return embeddedDependencies.value
.map((context) => {
const resolution = context.dependency.attribution?.resolution
return {
id: getEmbeddedDependencyId(context),
icon_url: context.project
? context.project.icon_url
: context.dependency.attribution?.flame_project?.icon_url,
name: context.project ? context.project.title : context.dependency.file_name,
version: context.version ? context.version.version_number : undefined,
link: context.project
? props.dependencyLinkCreator(context)
: resolution && 'link_to_work' in resolution
? resolution.link_to_work
: undefined,
hasProject: !!context.project,
}
})
.sort((a, b) => {
const priority = (row: typeof a) => {
if (row.hasProject) return 0
if (row.link) return 1
return 2
}
const priorityDiff = priority(a) - priority(b)
if (priorityDiff !== 0) {
return priorityDiff
}
const sortName = (name: string | undefined) => (name ?? '').replace(/[^a-zA-Z0-9]/g, '')
return sortName(a.name).localeCompare(sortName(b.name))
})
})
const contentTableColumns = computed(() => { const contentTableColumns = computed(() => {
const cols = [ const cols = [
{ {
@@ -128,7 +205,11 @@ const contentTableColumns = computed(() => {
label: formatMessage(defineMessage({ id: 'version.content.name', defaultMessage: 'Name' })), label: formatMessage(defineMessage({ id: 'version.content.name', defaultMessage: 'Name' })),
}, },
] ]
if (includedContent.value.some((x) => x.version)) { if (
includedContentLoading.value
? embeddedDependencies.value.some((context) => context.dependency.version_id)
: includedContent.value.some((row) => 'version' in row && row.version)
) {
cols.push({ cols.push({
key: 'version', key: 'version',
label: formatMessage( label: formatMessage(
@@ -318,7 +399,7 @@ const authorLink = computed(() =>
{{ formatMessage(projectCompatibilityMessages.minecraftJava) }} {{ formatMessage(projectCompatibilityMessages.minecraftJava) }}
<div class="flex gap-1 flex-wrap mt-2"> <div class="flex gap-1 flex-wrap mt-2">
<TagItem <TagItem
v-for="gameVersion in version.game_versions" v-for="gameVersion in gameVersionsToDisplay"
:key="`version-compat-game-version-${gameVersion}`" :key="`version-compat-game-version-${gameVersion}`"
>{{ gameVersion }}</TagItem >{{ gameVersion }}</TagItem
> >
@@ -414,7 +495,7 @@ const authorLink = computed(() =>
/> />
</div> </div>
</section> </section>
<section v-if="includedContent?.length > 0" id="content"> <section v-if="embeddedDependencies.length > 0" id="content">
<h3 class="mt-0 mb-2 text-lg font-semibold"> <h3 class="mt-0 mb-2 text-lg font-semibold">
{{ {{
formatMessage( formatMessage(
@@ -422,36 +503,60 @@ const authorLink = computed(() =>
) )
}} }}
</h3> </h3>
<Table :columns="contentTableColumns" :data="includedContent"> <Table :columns="contentTableColumns" :data="includedContent" row-key="id">
<template #header-actions> <template #header-actions>
<StyledInput <StyledInput
v-model="contentSearchQuery" v-model="contentSearchQuery"
:icon="SearchIcon" :icon="SearchIcon"
:placeholder="formatMessage(messages.searchContent)" :placeholder="formatMessage(messages.searchContent)"
:disabled="includedContentLoading"
clearable clearable
wrapper-class="w-full sm:w-64" wrapper-class="w-full sm:w-64"
/> />
</template> </template>
<template #cell-icon="{ row }"> <template #cell-icon="{ row }">
<AutoLink :to="row.link" tabindex="-1" class="flex" target="_blank"> <div
<Avatar v-if="row.icon_url" :src="row.icon_url" alt="" size="2rem" /> v-if="isIncludedContentLoadingRow(row)"
class="size-[2rem] shrink-0 rounded-[16%] bg-surface-3 animate-pulse"
/>
<AutoLink v-else :to="row.link" tabindex="-1" class="flex" target="_blank">
<Avatar v-if="row.hasProject || row.icon_url" :src="row.icon_url" alt="" size="2rem" />
<div v-else class="size-[2rem] flex items-center justify-center"> <div v-else class="size-[2rem] flex items-center justify-center">
<FileIcon class="size-5 text-secondary" /> <FileIcon class="size-5 text-secondary" />
</div> </div>
</AutoLink> </AutoLink>
</template> </template>
<template #cell-name="{ row }"> <template #cell-name="{ row }">
<div
v-if="isIncludedContentLoadingRow(row)"
class="h-4 max-w-[12rem] rounded-lg bg-surface-3 animate-pulse"
/>
<AutoLink <AutoLink
v-else
:to="row.link" :to="row.link"
class="flex w-fit" class="flex w-fit"
link-class="hover:underline hover:text-contrast" link-class="hover:underline hover:text-contrast"
target="_blank" target="_blank"
> >
{{ row.name }} {{ row.name }}
<ExternalIcon v-if="row.link?.startsWith('http')" class="shrink-0 ml-2" />
</AutoLink> </AutoLink>
</template> </template>
<template #cell-version="{ row }"> <template #cell-version="{ row }">
{{ (row.version ?? row.hasProject) ? formatMessage(commonMessages.unknownLabel) : '—' }} <div
v-if="isIncludedContentLoadingRow(row)"
class="h-4 w-16 rounded-lg bg-surface-3 animate-pulse"
/>
<AutoLink
v-else
:to="row.version ? row.link : undefined"
class="flex w-fit"
tabindex="-1"
link-class="hover:underline hover:text-contrast"
target="_blank"
>
{{ row.version ?? '—' }}
</AutoLink>
</template> </template>
</Table> </Table>
</section> </section>