New project cards (#5298)

* New project card

* no shadow on icons

* Remove updated label

* reduce tag count to 5

* improve envs

* fix: project card bottom row not growing

* move actions in grid mode

* focus changes + new project list component

* Allow more tags in grid mode, deprioritize non-loader tags

* fix prod deploy robots.txt

* remove unused id

* App cards

* prepr

* publish date + fix router links

* fix author hover underline in firefox

* perf: preload on search item hover

* remove unused filter

* remove option for old grid view

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Prospector
2026-02-07 11:18:59 -08:00
committed by GitHub
parent b6c22d6ca6
commit b005c1f522
46 changed files with 1343 additions and 1759 deletions

View File

@@ -300,42 +300,48 @@
)
.slice()
.sort((a, b) => b.downloads - a.downloads)"
:id="project.id"
:key="project.id"
:type="project.project_type"
:categories="project.categories"
:created-at="project.published"
:updated-at="project.updated"
:description="project.description"
:downloads="project.downloads ? project.downloads.toString() : '0'"
:follows="project.followers ? project.followers.toString() : '0'"
:featured-image="project.gallery.find((element) => element.featured)?.url"
:link="`/${project.project_type}/${project.slug ?? project.id}`"
:title="project.title"
:icon-url="project.icon_url"
:name="project.title"
:client-side="project.client_side"
:server-side="project.server_side"
:banner="project.gallery.find((element) => element.featured)?.url"
:summary="project.description"
:date-updated="project.updated"
:downloads="project.downloads ?? 0"
:followers="project.followers ?? 0"
:tags="project.categories"
:environment="{
clientSide: project.client_side,
serverSide: project.server_side,
}"
:color="project.color"
:show-updated-date="!canEdit && collection.id !== 'following'"
:show-created-date="!canEdit && collection.id !== 'following'"
:layout="
cosmetics.searchDisplayMode.collection === 'grid' ||
cosmetics.searchDisplayMode.collection === 'gallery'
? 'grid'
: 'list'
"
>
<button
v-if="canEdit"
class="iconified-button remove-btn"
:disabled="removing"
@click="() => removeProject(project)"
>
<SpinnerIcon v-if="removing" class="animate-spin" aria-hidden="true" />
<XIcon v-else aria-hidden="true" />
{{ formatMessage(messages.removeProjectButton) }}
</button>
<button
v-if="collection.id === 'following'"
class="iconified-button"
@click="unfollowProject(project)"
>
<HeartMinusIcon aria-hidden="true" />
{{ formatMessage(messages.unfollowProjectButton) }}
</button>
<template v-if="canEdit || collection.id === 'following'" #actions>
<button
v-if="canEdit"
class="iconified-button remove-btn"
:disabled="removing"
@click="() => removeProject(project)"
>
<SpinnerIcon v-if="removing" class="animate-spin" aria-hidden="true" />
<XIcon v-else aria-hidden="true" />
{{ formatMessage(messages.removeProjectButton) }}
</button>
<button
v-if="collection.id === 'following'"
class="iconified-button"
@click="unfollowProject(project)"
>
<HeartMinusIcon aria-hidden="true" />
{{ formatMessage(messages.unfollowProjectButton) }}
</button>
</template>
</ProjectCard>
</div>
<div v-else>
@@ -395,6 +401,7 @@ import {
normalizeChildren,
NormalPage,
OverflowMenu,
ProjectCard,
RadioButtons,
SidebarCard,
useRelativeTime,
@@ -406,7 +413,6 @@ import dayjs from 'dayjs'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import NavTabs from '~/components/ui/NavTabs.vue'
import ProjectCard from '~/components/ui/ProjectCard.vue'
import { asEncodedJsonArray, fetchSegmented } from '~/utils/fetch-helpers.ts'
const { handleError } = injectNotificationManager()

View File

@@ -1,41 +1,48 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BookmarkIcon,
CheckIcon,
DownloadIcon,
FilterIcon,
GameIcon,
GridIcon,
HeartIcon,
ImageIcon,
InfoIcon,
LeftArrowIcon,
ListIcon,
MoreVerticalIcon,
SearchIcon,
XIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import {
Avatar,
Button,
ButtonStyled,
Checkbox,
defineMessages,
DropdownSelect,
injectModrinthClient,
injectNotificationManager,
NewProjectCard,
Pagination,
ProjectCard,
ProjectCardList,
SearchFilterControl,
SearchSidebarFilter,
type SortType,
Toggle,
useSearch,
useVIntl,
} from '@modrinth/ui'
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
import { useQueryClient } from '@tanstack/vue-query'
import { useThrottleFn } from '@vueuse/core'
import { computed, type Reactive, watch } from 'vue'
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import ProjectCard from '~/components/ui/ProjectCard.vue'
import { projectQueryOptions } from '~/composables/queries/project'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
@@ -53,6 +60,22 @@ const flags = useFeatureFlags()
const auth = await useAuth()
const { handleError } = injectNotificationManager()
const modrinthClient = injectModrinthClient()
const queryClient = useQueryClient()
let prefetchTimeout: ReturnType<typeof setTimeout> | null = null
function handleProjectHover(result: Labrinth.Search.v2.ResultSearchProject) {
if (prefetchTimeout) clearTimeout(prefetchTimeout)
prefetchTimeout = setTimeout(() => {
const slug = result.slug || result.project_id
queryClient.prefetchQuery(projectQueryOptions.v2(slug, modrinthClient))
queryClient.prefetchQuery(projectQueryOptions.v3(result.project_id, modrinthClient))
queryClient.prefetchQuery(projectQueryOptions.members(result.project_id, modrinthClient))
queryClient.prefetchQuery(projectQueryOptions.dependencies(result.project_id, modrinthClient))
queryClient.prefetchQuery(projectQueryOptions.versionsV3(result.project_id, modrinthClient))
}, 150)
}
const currentType = computed(() =>
queryAsStringOrEmpty(route.params.type).replaceAll(/^\/|s\/?$/g, ''),
@@ -177,6 +200,14 @@ const currentMaxResultsOptions = computed(
() => maxResultsForView.value[resultsDisplayMode.value] ?? [20],
)
const LOADER_FILTER_TYPES = [
'mod_loader',
'plugin_loader',
'modpack_loader',
'shader_loader',
'plugin_platform',
] as const
const {
// Selections
query,
@@ -198,6 +229,34 @@ const {
createPageParams,
} = useSearch(projectTypes, tags, serverFilters)
const selectedFilterTags = computed(() =>
currentFilters.value
.filter(
(f) =>
f.type.startsWith('category_') ||
LOADER_FILTER_TYPES.includes(f.type as (typeof LOADER_FILTER_TYPES)[number]),
)
.map((f) => f.option),
)
const excludeLoaders = computed(
() =>
currentFilters.value.some((f) =>
LOADER_FILTER_TYPES.includes(f.type as (typeof LOADER_FILTER_TYPES)[number]),
) || ['resourcepack', 'datapack'].includes(currentType.value),
)
const loadersNotForThisType = computed(() => {
return (
tags.value?.loaders
?.filter((loader) => !loader.supported_project_types.includes(currentType.value))
?.map((loader) => loader.name) ?? []
)
})
const deprioritizedTags = computed(() => {
return [...selectedFilterTags.value, ...loadersNotForThisType.value]
})
const messages = defineMessages({
gameVersionProvidedByServer: {
id: 'search.filter.locked.server-game-version.title',
@@ -353,7 +412,7 @@ function cycleSearchDisplayMode() {
}
cosmetics.value.searchDisplayMode[resultsDisplayLocation.value] = cycleValue(
cosmetics.value.searchDisplayMode[resultsDisplayLocation.value],
tags.value.projectViewModes,
tags.value.projectViewModes.filter((x) => x !== 'grid'),
)
setClosestMaxResults()
}
@@ -611,84 +670,96 @@ useSeoMeta({
<p>No results found for your query!</p>
</div>
<div v-else class="search-results-container">
<div
id="search-results"
class="project-list"
:class="'display-mode--' + resultsDisplayMode"
role="list"
<ProjectCardList
aria-label="Search results"
:layout="
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
>
<template v-for="result in results?.hits" :key="result.project_id">
<ProjectCard
v-if="flags.oldProjectCards"
:id="result.slug ? result.slug : result.project_id"
:display="resultsDisplayMode"
:featured-image="
result.featured_gallery ? result.featured_gallery : result.gallery[0]
"
:type="result.project_type"
:author="result.author"
:name="result.title"
:description="result.description"
:created-at="result.date_created"
:updated-at="result.date_modified"
:downloads="result.downloads.toString()"
:follows="result.follows.toString()"
:icon-url="result.icon_url"
:client-side="result.client_side"
:server-side="result.server_side"
:categories="result.display_categories"
:search="true"
:show-updated-date="!server && currentSortType.name !== 'newest'"
:show-created-date="!server"
:hide-loaders="
projectType ? ['resourcepack', 'datapack'].includes(projectType.id) : false
"
:color="result.color ?? undefined"
>
<template v-if="server">
<button
v-if="
(result as InstallableSearchResult).installed ||
(server?.content?.data &&
server.content.data.find(
(x: InstallableMod) => x.project_id === result.project_id,
)) ||
server.general?.project?.id === result.project_id
"
disabled
class="btn btn-outline btn-primary"
>
<CheckIcon />
Installed
</button>
<button
v-else-if="(result as InstallableSearchResult).installing"
disabled
class="btn btn-outline btn-primary"
>
Installing...
</button>
<button
v-else
class="btn btn-outline btn-primary"
@click="serverInstall(result as InstallableSearchResult)"
>
<DownloadIcon />
Install
</button>
<ProjectCard
v-for="result in results?.hits"
:key="result.project_id"
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
:title="result.title"
:icon-url="result.icon_url"
:author="{ name: result.author, link: `/user/${result.author}` }"
:date-updated="result.date_modified"
:date-published="result.date_created"
:displayed-date="currentSortType.name === 'newest' ? 'published' : 'updated'"
:downloads="result.downloads"
:summary="result.description"
:tags="result.display_categories"
:all-tags="result.categories"
:deprioritized-tags="deprioritizedTags"
:exclude-loaders="excludeLoaders"
:followers="result.follows"
:banner="result.featured_gallery ?? undefined"
:color="result.color ?? undefined"
:environment="
['mod', 'modpack'].includes(currentType)
? {
clientSide: result.client_side,
serverSide: result.server_side,
}
: undefined
"
:layout="
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
@hover="handleProjectHover(result)"
>
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
<template v-if="flags.showDiscoverProjectButtons">
<ButtonStyled color="brand">
<button>
<DownloadIcon />
Download
</button>
</ButtonStyled>
<ButtonStyled circular>
<button>
<HeartIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<button>
<BookmarkIcon />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button>
<MoreVerticalIcon />
</button>
</ButtonStyled>
</template>
</ProjectCard>
<NuxtLink
v-if="flags.newProjectCards"
:to="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
>
<NewProjectCard :project="result" :categories="result.display_categories">
<template v-if="false" #actions></template>
</NewProjectCard>
</NuxtLink>
</template>
</div>
<template v-else-if="server">
<ButtonStyled color="brand" type="outlined">
<button
v-if="
(result as InstallableSearchResult).installed ||
(server?.content?.data &&
server.content.data.find(
(x: InstallableMod) => x.project_id === result.project_id,
)) ||
server.general?.project?.id === result.project_id
"
disabled
>
<CheckIcon />
Installed
</button>
<button v-else-if="(result as InstallableSearchResult).installing" disabled>
Installing...
</button>
<button v-else @click="serverInstall(result as InstallableSearchResult)">
<DownloadIcon />
Install
</button>
</ButtonStyled>
</template>
</template>
</ProjectCard>
</ProjectCardList>
</div>
<div class="pagination-after">
<pagination
@@ -845,10 +916,6 @@ useSeoMeta({
margin: 2rem;
}
#search-results {
min-height: 20vh;
}
@media screen and (min-width: 750px) {
.search-controls {
flex-wrap: nowrap;

View File

@@ -125,24 +125,25 @@
<div class="results display-mode--list">
<ProjectCard
v-for="result in searchProjects"
:id="result.slug ? result.slug : result.project_id"
:key="result.project_id"
class="small-mode gradient-border"
:type="result.project_type"
:author="result.author"
:name="result.title"
:description="result.description"
:created-at="result.date_created"
:updated-at="result.date_modified"
:downloads="result.downloads.toString()"
:follows="result.follows.toString()"
class="gradient-border"
:link="`/${result.project_type}/${result.slug ? result.slug : result.project_id}`"
:title="result.title"
:author="{ name: result.author, link: `/user/${result.author}` }"
:summary="result.description"
:date-updated="result.date_modified"
:date-published="result.date_created"
:displayed-date="sortType === 'newest' ? 'published' : 'updated'"
:downloads="result.downloads"
:followers="result.follows"
:icon-url="result.icon_url"
:client-side="result.client_side"
:server-side="result.server_side"
:categories="result.display_categories.slice(0, 3)"
:search="true"
:show-updated-date="true"
:environment="{
clientSide: result.client_side,
serverSide: result.server_side,
}"
:tags="result.display_categories.slice(0, 3)"
:color="result.color"
layout="list"
/>
</div>
</div>
@@ -445,6 +446,7 @@ import {
commonMessages,
defineMessages,
IntlFormatted,
ProjectCard,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
@@ -454,7 +456,6 @@ import { Multiselect } from 'vue-multiselect'
import ATLauncherLogo from '~/assets/images/external/atlauncher.svg?component'
import PrismLauncherLogo from '~/assets/images/external/prism.svg?component'
import LatestNewsRow from '~/components/ui/news/LatestNewsRow.vue'
import ProjectCard from '~/components/ui/ProjectCard.vue'
import { homePageNotifs, homePageProjects, homePageSearch } from '~/generated/state.json'
const formatRelativeTime = useRelativeTime()

View File

@@ -204,29 +204,28 @@
)
.slice()
.sort((a, b) => b.downloads - a.downloads)"
:id="project.slug || project.id"
:key="project.id"
:name="project.name"
:display="cosmetics.searchDisplayMode.user"
:featured-image="project.gallery.find((element) => element.featured)?.url"
project-type-url="project"
:description="project.summary"
:created-at="project.published"
:updated-at="project.updated"
:downloads="project.downloads.toString()"
:follows="project.followers.toString()"
:link="`/${project.project_types[0] ?? 'project'}/${project.slug || project.id}`"
:title="project.name"
:icon-url="project.icon_url"
:categories="project.categories"
:client-side="project.client_side"
:server-side="project.server_side"
:banner="project.gallery.find((element) => element.featured)?.url"
:summary="project.summary"
:date-updated="project.updated"
:downloads="project.downloads"
:followers="project.followers"
:tags="project.categories"
:environment="{
clientSide: project.client_side,
serverSide: project.server_side,
}"
:status="
auth.user &&
(auth.user.id! === (user as any).id || tags.staffRoles.includes(auth.user.role))
? (project.status as ProjectStatus)
: undefined
"
:type="project.project_types[0] ?? 'project'"
:color="project.color"
layout="list"
/>
</div>
</template>
@@ -267,6 +266,7 @@ import {
commonMessages,
ContentPageHeader,
OverflowMenu,
ProjectCard,
useVIntl,
} from '@modrinth/ui'
import type { Organization, ProjectStatus, ProjectType, ProjectV3 } from '@modrinth/utils'
@@ -277,7 +277,6 @@ import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import NavStack from '~/components/ui/NavStack.vue'
import NavTabs from '~/components/ui/NavTabs.vue'
import ProjectCard from '~/components/ui/ProjectCard.vue'
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
import {
OrganizationContext,

View File

@@ -59,41 +59,17 @@
class="radio shrink-0"
/>
<RadioButtonIcon v-else class="radio shrink-0" />
Rows
List
</div>
</button>
<button
class="preview-radio button-base"
:class="{
selected: cosmetics.searchDisplayMode[projectType.id] === 'grid',
selected:
cosmetics.searchDisplayMode[projectType.id] === 'gallery' ||
cosmetics.searchDisplayMode[projectType.id] === 'grid',
}"
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'grid')"
>
<div class="preview">
<div class="layout-grid-mode">
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
</div>
</div>
<div class="label">
<RadioButtonCheckedIcon
v-if="cosmetics.searchDisplayMode[projectType.id] === 'grid'"
class="radio shrink-0"
/>
<RadioButtonIcon v-else class="radio shrink-0" />
Grid
</div>
</button>
<button
class="preview-radio button-base"
:class="{
selected: cosmetics.searchDisplayMode[projectType.id] === 'gallery',
}"
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'gallery')"
>
<div class="preview">
<div class="layout-gallery-mode">
@@ -105,11 +81,14 @@
</div>
<div class="label">
<RadioButtonCheckedIcon
v-if="cosmetics.searchDisplayMode[projectType.id] === 'gallery'"
v-if="
cosmetics.searchDisplayMode[projectType.id] === 'gallery' ||
cosmetics.searchDisplayMode[projectType.id] === 'grid'
"
class="radio shrink-0"
/>
<RadioButtonIcon v-else class="radio shrink-0" />
Gallery
Grid
</div>
</button>
</div>

View File

@@ -301,27 +301,32 @@
)
.slice()
.sort((a, b) => b.downloads - a.downloads)"
:id="project.slug || project.id"
:key="project.id"
:name="project.title"
:display="cosmetics.searchDisplayMode.user"
:featured-image="project.gallery.find((element) => element.featured)?.url"
:description="project.description"
:created-at="project.published"
:updated-at="project.updated"
:downloads="project.downloads.toString()"
:follows="project.followers.toString()"
:link="`/${project.project_type ?? 'project'}/${project.slug ? project.slug : project.id}`"
:title="project.title"
:icon-url="project.icon_url"
:categories="project.categories"
:client-side="project.client_side"
:server-side="project.server_side"
:status="
auth.user && (auth.user.id === user.id || tags.staffRoles.includes(auth.user.role))
? project.status
: null
:date-updated="project.updated"
:downloads="project.downloads"
:summary="project.description"
:tags="[...project.categories]"
:all-tags="[
...project.categories,
...project.loaders,
...project.additional_categories,
]"
:followers="project.followers"
:color="project.color ?? undefined"
:environment="{
clientSide: project.client_side,
serverSide: project.server_side,
}"
:layout="
cosmetics.searchDisplayMode.user === 'grid' ||
cosmetics.searchDisplayMode.user === 'gallery'
? 'grid'
: 'list'
"
:type="project.project_type"
:color="project.color"
:status="project.status"
/>
</div>
</div>
@@ -493,6 +498,7 @@ import {
IntlFormatted,
NewModal,
OverflowMenu,
ProjectCard,
TagItem,
useRelativeTime,
useVIntl,
@@ -511,7 +517,6 @@ import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import NavTabs from '~/components/ui/NavTabs.vue'
import ProjectCard from '~/components/ui/ProjectCard.vue'
import { reportUser } from '~/utils/report-helpers.ts'
const data = useNuxtApp()