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

@@ -1,85 +0,0 @@
<template>
<div class="button-base p-4 bg-bg-raised rounded-xl flex gap-3 group">
<div class="icon">
<Avatar :src="project.icon_url" size="96px" class="search-icon" />
</div>
<div class="flex flex-col gap-2 overflow-hidden">
<div class="gap-2 overflow-hidden no-wrap text-ellipsis">
<span class="text-lg font-extrabold text-contrast m-0 leading-none">{{
project.title
}}</span>
<span v-if="project.author" class="text-secondary"> by {{ project.author }}</span>
</div>
<div class="m-0 line-clamp-2">
{{ project.description }}
</div>
<div class="mt-auto flex items-center gap-1 no-wrap">
<TagsIcon class="h-4 w-4 shrink-0" />
<div
v-for="tag in categories"
:key="tag"
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
>
<FormattedTag :tag="tag" />
</div>
</div>
</div>
<div class="flex flex-col gap-2 items-end shrink-0 ml-auto">
<div class="flex items-center gap-2">
<DownloadIcon class="shrink-0" />
<span>
{{ formatNumber(project.downloads) }}
<span class="text-secondary">downloads</span>
</span>
</div>
<div class="flex items-center gap-2">
<HeartIcon class="shrink-0" />
<span>
{{ formatNumber(project.follows ?? project.followers) }}
<span class="text-secondary">followers</span>
</span>
</div>
<div class="mt-auto relative">
<div
:class="{
'group-hover:-translate-y-3 group-hover:opacity-0 group-focus-within:opacity-0 group-hover:scale-95 group-focus-within:scale-95 transition-all':
$slots.actions,
}"
class="flex items-center gap-2"
>
<HistoryIcon class="shrink-0" />
<span>
<span class="text-secondary">Updated</span>
{{ formatRelativeTime(project.date_modified ?? project.updated) }}
</span>
</div>
<div
class="opacity-0 scale-95 translate-y-3 group-hover:translate-y-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:opacity-100 group-focus-within:scale-100 absolute bottom-0 right-0 transition-all w-fit"
>
<slot name="actions" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { DownloadIcon, HeartIcon, HistoryIcon, TagsIcon } from '@modrinth/assets'
import { Avatar, FormattedTag } from '@modrinth/ui'
import { formatNumber } from '@modrinth/utils'
import { useRelativeTime } from '../../composables'
const formatRelativeTime = useRelativeTime()
defineProps({
project: {
type: Object,
required: true,
},
categories: {
type: Array,
required: true,
},
})
</script>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
withDefaults(
defineProps<{
layout?: 'list' | 'grid'
}>(),
{
layout: 'list',
},
)
</script>
<template>
<div
class="gap-3"
:class="{
'flex flex-col': layout === 'list',
'grid grid-project-list': layout === 'grid',
}"
role="list"
>
<slot />
</div>
</template>
<style scoped lang="scss">
.grid-project-list {
display: grid;
grid-template-columns: repeat(2, minmax(150px, 1fr));
}
</style>

View File

@@ -15,7 +15,11 @@
<template #stats>
<div
v-tooltip="
`${formatNumber(project.downloads, false)} download${project.downloads !== 1 ? 's' : ''}`
capitalizeString(
formatMessage(commonMessages.projectDownloads, {
count: formatNumber(project.downloads, false),
}),
)
"
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold cursor-help"
>
@@ -24,7 +28,11 @@
</div>
<div
v-tooltip="
`${formatNumber(project.followers, false)} follower${project.downloads !== 1 ? 's' : ''}`
capitalizeString(
formatMessage(commonMessages.projectFollowers, {
count: formatNumber(project.followers, false),
}),
)
"
class="flex items-center gap-2 border-0 border-solid border-divider pr-4 cursor-help"
:class="{ 'md:border-r': project.categories.length > 0 }"
@@ -54,9 +62,11 @@
</template>
<script setup lang="ts">
import { DownloadIcon, HeartIcon, TagsIcon } from '@modrinth/assets'
import { formatNumber, type Project } from '@modrinth/utils'
import { capitalizeString, formatNumber, type Project } from '@modrinth/utils'
import { useRouter } from 'vue-router'
import { useVIntl } from '../../composables'
import { commonMessages } from '../../utils'
import Avatar from '../base/Avatar.vue'
import ContentPageHeader from '../base/ContentPageHeader.vue'
import FormattedTag from '../base/FormattedTag.vue'
@@ -64,6 +74,7 @@ import TagItem from '../base/TagItem.vue'
import ProjectStatusBadge from './ProjectStatusBadge.vue'
const router = useRouter()
const { formatMessage } = useVIntl()
withDefaults(
defineProps<{

View File

@@ -5,26 +5,29 @@
<div>
<BookTextIcon aria-hidden="true" />
<div>
Licensed
<a
v-if="project.license.url"
class="text-link hover:underline"
:href="project.license.url"
:target="linkTarget"
rel="noopener nofollow ugc"
>
{{ licenseIdDisplay }}
<ExternalIcon aria-hidden="true" class="external-icon ml-1 mt-[-1px] inline" />
</a>
<span
v-else-if="
project.license.id === 'LicenseRef-All-Rights-Reserved' ||
!project.license.id.includes('LicenseRef')
"
>
{{ licenseIdDisplay }}
</span>
<span v-else>{{ licenseIdDisplay }}</span>
<IntlFormatted :message-id="messages.licensed">
<template #~license>
<a
v-if="project.license.url"
class="text-link hover:underline"
:href="project.license.url"
:target="linkTarget"
rel="noopener nofollow ugc"
>
{{ licenseIdDisplay }}
<ExternalIcon aria-hidden="true" class="external-icon ml-1 mt-[-1px] inline" />
</a>
<span
v-else-if="
project.license.id === 'LicenseRef-All-Rights-Reserved' ||
!project.license.id.includes('LicenseRef')
"
>
{{ licenseIdDisplay }}
</span>
<span v-else>{{ licenseIdDisplay }}</span>
</template>
</IntlFormatted>
</div>
</div>
<div
@@ -33,13 +36,19 @@
>
<CalendarIcon aria-hidden="true" />
<div>
{{ capitalizeString(formatMessage(messages.published, { date: publishedDate })) }}
{{
capitalizeString(
formatMessage(commonMessages.projectPublished, { date: publishedDate }),
)
}}
</div>
</div>
<div v-else v-tooltip="dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')">
<CalendarIcon aria-hidden="true" />
<div>
{{ capitalizeString(formatMessage(messages.created, { date: createdDate })) }}
{{
capitalizeString(formatMessage(commonMessages.projectCreated, { date: createdDate }))
}}
</div>
</div>
<div
@@ -48,7 +57,11 @@
>
<ScaleIcon aria-hidden="true" />
<div>
{{ capitalizeString(formatMessage(messages.submitted, { date: submittedDate })) }}
{{
capitalizeString(
formatMessage(commonMessages.projectSubmitted, { date: submittedDate }),
)
}}
</div>
</div>
<div
@@ -57,7 +70,9 @@
>
<VersionIcon aria-hidden="true" />
<div>
{{ capitalizeString(formatMessage(messages.updated, { date: updatedDate })) }}
{{
capitalizeString(formatMessage(commonMessages.projectUpdated, { date: updatedDate }))
}}
</div>
</div>
</div>
@@ -72,6 +87,7 @@ import { computed } from 'vue'
import { useRelativeTime } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages'
import { IntlFormatted } from '../base'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
@@ -123,21 +139,5 @@ const messages = defineMessages({
id: 'project.about.details.licensed',
defaultMessage: 'Licensed {license}',
},
created: {
id: 'project.about.details.created',
defaultMessage: 'Created {date}',
},
submitted: {
id: 'project.about.details.submitted',
defaultMessage: 'Submitted {date}',
},
published: {
id: 'project.about.details.published',
defaultMessage: 'Published {date}',
},
updated: {
id: 'project.about.details.updated',
defaultMessage: 'Updated {date}',
},
})
</script>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { Menu } from 'floating-vue'
import { TagItem, TagTagItem } from '../base'
defineProps<{
tags: string[]
}>()
defineOptions({
inheritAttrs: false,
})
</script>
<template>
<Menu :delay="{ hide: 50, show: 0 }" no-auto-focus>
<TagItem v-if="tags.length > 0" v-bind="$attrs" tabindex="0"> +{{ tags.length }} </TagItem>
<template #popper>
<div class="flex gap-1 flex-wrap max-w-[20rem]">
<TagTagItem
v-for="tag in tags"
:key="'overflow-tag-' + tag"
hide-non-loader-icon
:tag="tag"
/>
</div>
</template>
</Menu>
</template>

View File

@@ -0,0 +1,355 @@
<template>
<div class="w-full" @mouseenter="$emit('hover')">
<SmartClickable class="w-full project-card-container">
<template v-if="link" #clickable>
<AutoLink
:to="link"
class="rounded-xl no-outline no-click-animation custom-focus-indicator"
></AutoLink>
</template>
<div v-if="layout === 'grid'" :class="[baseCardStyle, 'flex flex-col']">
<div
:style="{ '--_project-color': cssColor }"
class="relative bg-project-gradient overflow-clip aspect-[2/1] w-full border-0 border-b-[1px] border-solid border-surface-4"
>
<img
v-if="banner"
:src="banner"
alt=""
class="absolute w-full h-full inset-0 object-cover object-center"
/>
<img
v-else
src="https://cdn-raw.modrinth.com/landing-new/landing.webp"
alt=""
class="absolute w-full h-full inset-0 object-cover object-center placeholder-banner scale-[200%]"
/>
</div>
<div class="p-4 flex flex-col gap-3 grow">
<div class="flex gap-3">
<Avatar :src="iconUrl" size="96px" class="project-card__icon" no-shadow />
<div class="flex flex-col gap-2 w-full">
<div class="grid grid-cols-[1fr_auto] gap-4">
<div class="flex flex-col gap-1">
<div class="flex gap-2 items-center">
<ProjectCardTitle :title="title" compact />
<ProjectCardAuthor v-if="author" :author="author" />
</div>
<div class="m-0 font-normal line-clamp-2">
{{ summary }}
</div>
</div>
</div>
</div>
</div>
<div class="flex gap-2 shrink-0 empty:hidden smart-clickable:allow-pointer-events">
<slot name="actions" />
</div>
<div class="mt-auto flex flex-col gap-3 flex-wrap overflow-hidden justify-between grow">
<div class="flex items-center gap-1 flex-wrap overflow-hidden">
<ProjectCardEnvironment
v-if="environment"
:client-side="environment.clientSide"
:server-side="environment.serverSide"
/>
<ProjectCardTags
v-if="tags"
:tags="tags"
:exclude-loaders="excludeLoaders"
:deprioritized-tags="deprioritizedTags"
:max-tags="6 + (!!environment ? 0 : 1)"
/>
</div>
<div
v-if="downloads !== undefined || followers !== undefined"
class="flex items-center gap-3 justify-between flex-wrap"
>
<div class="flex items-center gap-3 no-wrap flex-wrap">
<ProjectCardStats :downloads="downloads" :followers="followers" />
</div>
<ProjectCardDate
v-if="date && autoDisplayDate"
:type="autoDisplayDate"
:date="date"
/>
</div>
</div>
</div>
</div>
<div
v-else
:class="[
baseCardStyle,
'p-4 grid grid-project-card-list gap-x-3 gap-y-2',
{ 'has-actions': !!$slots.actions },
]"
>
<Avatar
:src="iconUrl"
size="100px"
class="project-card__icon grid-project-card-list__icon"
no-shadow
/>
<div class="flex flex-col gap-2 grid-project-card-list__info">
<div class="flex gap-2 items-center">
<ProjectCardTitle :title="title" />
<ProjectCardAuthor v-if="author" :author="author" />
<ProjectStatusBadge v-if="status" :status="status" />
</div>
<div class="project-card-summary m-0 font-normal line-clamp-2">
{{ summary }}
</div>
</div>
<div
v-if="!!$slots.actions"
class="flex gap-1 shrink-0 ml-auto empty:hidden smart-clickable:allow-pointer-events grid-project-card-list__actions"
>
<slot name="actions" />
</div>
<div
class="flex flex-col gap-3 items-end shrink-0 ml-auto empty:hidden grid-project-card-list__stats"
:class="{ 'mt-3': !!$slots.actions }"
>
<div class="flex items-center gap-3">
<ProjectCardStats :downloads="downloads" :followers="followers" />
</div>
<ProjectCardDate v-if="date && autoDisplayDate" :type="autoDisplayDate" :date="date" />
</div>
<div class="mt-auto flex items-center gap-3 grid-project-card-list__tags">
<div class="flex items-center gap-1 flex-wrap">
<ProjectCardEnvironment
v-if="environment"
:client-side="environment.clientSide"
:server-side="environment.serverSide"
/>
<ProjectCardTags
v-if="tags"
:tags="tags"
:extra-tags="extraTags"
:exclude-loaders="excludeLoaders"
:deprioritized-tags="deprioritizedTags"
:max-tags="(!!$slots.actions ? 4 : 5) + (!!environment ? 0 : 1)"
/>
</div>
</div>
</div>
</SmartClickable>
</div>
</template>
<script setup lang="ts">
import { Avatar } from '@modrinth/ui'
import type { ProjectStatus } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { AutoLink } from '../../base'
import { SmartClickable } from '../../base/index.ts'
import ProjectStatusBadge from '../ProjectStatusBadge.vue'
import ProjectCardAuthor from './ProjectCardAuthor.vue'
import ProjectCardDate from './ProjectCardDate.vue'
import ProjectCardEnvironment, {
type ProjectCardEnvironmentProps,
} from './ProjectCardEnvironment.vue'
import ProjectCardStats from './ProjectCardStats.vue'
import ProjectCardTags from './ProjectCardTags.vue'
import ProjectCardTitle from './ProjectCardTitle.vue'
defineEmits<{
hover: []
}>()
const props = defineProps<{
layout: 'list' | 'grid'
link?: string | (() => void)
iconUrl?: string
title: string
author?: {
name: string
link?: string
}
summary?: string
tags?: string[]
allTags?: string[]
deprioritizedTags?: string[]
excludeLoaders?: boolean
downloads?: number
followers?: number
dateUpdated?: string
datePublished?: string
displayedDate?: 'updated' | 'published'
banner?: string
color?: string | number
environment?: ProjectCardEnvironmentProps
status?: ProjectStatus
}>()
const baseCardStyle =
'w-full h-full border-[1px] border-solid border-surface-4 overflow-hidden bg-bg-raised rounded-2xl group transition-all smart-clickable:outline-on-focus smart-clickable:highlight-on-hover'
const updatedDate = computed(() =>
props.dateUpdated ? dayjs(props.dateUpdated).toDate() : undefined,
)
const publishedDate = computed(() =>
props.datePublished ? dayjs(props.datePublished).toDate() : undefined,
)
const autoDisplayDate = computed(() => {
if (props.displayedDate) {
return props.displayedDate
} else if (props.dateUpdated) {
return 'updated'
} else if (props.datePublished) {
return 'published'
} else {
return undefined
}
})
const date = computed(() => {
if (autoDisplayDate.value === 'updated') {
return updatedDate.value
} else if (autoDisplayDate.value === 'published') {
return publishedDate.value
}
return undefined
})
const extraTags = computed(() => props.allTags?.filter((tag) => !props.tags?.includes(tag)))
const cssColor = computed(() => {
if (props.color === undefined || typeof props.color === 'string') {
return props.color
}
const color = props.color >>> 0
const b = color & 0xff
const g = (color & 0xff00) >>> 8
const r = (color & 0xff0000) >>> 16
return 'rgba(' + [r, g, b, 1].join(',') + ')'
})
</script>
<style scoped>
.no-outline {
outline: none;
}
:deep(.project-card-container) {
container-type: inline-size;
}
.grid-project-card-list {
grid-template:
'icon info stats stats'
'icon info stats stats'
'icon tags tags tags';
grid-template-columns: auto 1fr auto auto;
}
.grid-project-card-list.has-actions {
grid-template:
'icon info actions actions'
'icon info dummy stats'
'icon tags tags stats';
grid-template-columns: auto 1fr auto auto;
}
.grid-project-card-list__icon {
grid-area: icon;
}
.grid-project-card-list__info {
grid-area: info;
}
.grid-project-card-list__actions {
grid-area: actions;
}
.grid-project-card-list__stats {
grid-area: stats;
}
.grid-project-card-list__tags {
grid-area: tags;
}
@container (width < 850px) {
.project-card__icon {
--_override-size: 64px;
}
.grid-project-card-list {
grid-template:
'icon info stats'
'icon info stats'
'tags tags tags';
grid-template-columns: auto 1fr auto;
}
.grid-project-card-list.has-actions {
grid-template:
'icon info actions'
'icon info stats'
'tags tags stats';
grid-template-columns: auto 1fr auto;
}
}
@container (width < 550px) {
.project-card__icon {
--_override-size: 64px;
}
.grid-project-card-list {
grid-template:
'icon info'
'icon info'
'tags tags'
'stats stats';
grid-template-columns: auto 1fr;
}
.grid-project-card-list.has-actions {
grid-template:
'icon info'
'icon info'
'tags tags'
'stats stats'
'actions actions';
grid-template-columns: auto 1fr;
}
.grid-project-card-list__stats,
.grid-project-card-list__actions {
@apply items-start w-full;
}
.grid-project-card-list__info {
@apply gap-0.5;
}
.project-card-summary {
@apply text-sm;
}
}
/*noinspection CssUnresolvedCustomProperty*/
.bg-project-gradient {
--_gradient-start: var(--_project-color, #000);
--_gradient-end: var(--_project-color, #000);
@supports (background-color: oklch(from var(--_project-color, #000) l c h)) {
--_gradient-start: oklch(
from var(--_project-color, #000) calc(l * 0.8) calc(c * 0.8) calc(h + 15)
);
--_gradient-end: oklch(from var(--_project-color, #000) calc(l * 0.5) calc(c * 0.9) h);
}
background-color: var(--_gradient-start);
background-image: linear-gradient(to bottom right, var(--_gradient-start), var(--_gradient-end));
}
.placeholder-banner {
opacity: 0.7;
}
</style>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { ExternalIcon } from '@modrinth/assets'
import { AutoLink } from '../../base'
export type ProjectCardAuthorDetails = {
name: string
link?: string
}
defineProps<{
author: ProjectCardAuthorDetails
}>()
</script>
<template>
<!-- author will try to be full width, but no longer than the proper fit. but this also allows it to truncate if necessary -->
<!-- the weird padding and negative margin are to include the potential hover underline in bounding box which affects rendering on firefox -->
<span
class="line-clamp-1 break-all text-secondary font-normal max-w-fit w-full pb-[2px] mb-[-2px]"
>
by
<AutoLink
:to="author.link"
:class="
author.link
? 'custom-focus-indicator text-inherit outline-none group focus-visible:text-[--color-focus-ring] smart-clickable:allow-pointer-events'
: ''
"
>
<span
class="group-focus:underline group-focus:brightness-[--hover-brightness] group-hover:brightness-[--hover-brightness] group-hover:underline"
>
{{ author.name }}
</span>
<ExternalIcon v-if="author.link?.startsWith('http')" class="shrink-0 ml-1" />
</AutoLink>
</span>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { CalendarIcon, HistoryIcon } from '@modrinth/assets'
import { capitalizeString } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { useRelativeTime, useVIntl } from '../../../composables'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const props = defineProps<{
date: Date
type: 'updated' | 'published'
}>()
const formattedDate = computed(() => dayjs(props.date).format('MMMM D, YYYY [at] h:mm A'))
const types = {
updated: {
icon: HistoryIcon,
tooltip: {
id: 'project-card.date.updated.tooltip',
defaultMessage: 'Updated {date}',
},
},
published: {
icon: CalendarIcon,
tooltip: {
id: 'project-card.date.published.tooltip',
defaultMessage: 'Published {date}',
},
},
}
const tooltip = computed(() =>
capitalizeString(formatMessage(types[props.type].tooltip, { date: formattedDate.value })),
)
</script>
<template>
<div v-tooltip="tooltip" class="flex items-center gap-2 smart-clickable:allow-pointer-events">
<component :is="types[props.type].icon" class="size-5 shrink-0" />
{{ capitalizeString(formatRelativeTime(date)) }}
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ClientIcon, GlobeIcon, ServerIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '../../../composables'
import { TagItem } from '../../base'
const { formatMessage } = useVIntl()
export type ProjectCardEnvironmentProps = {
clientSide: Labrinth.Projects.v2.Environment
serverSide: Labrinth.Projects.v2.Environment
}
defineProps<ProjectCardEnvironmentProps>()
const messages = defineMessages({
clientOrServer: {
id: 'project-card.environment.client-or-server',
defaultMessage: 'Client or server',
},
clientAndServer: {
id: 'project-card.environment.client-and-server',
defaultMessage: 'Client and server',
},
client: {
id: 'project-card.environment.client',
defaultMessage: 'Client',
},
server: {
id: 'project-card.environment.server',
defaultMessage: 'Server',
},
})
</script>
<template>
<TagItem class="empty:hidden">
<template v-if="clientSide === 'optional' && serverSide === 'optional'">
<GlobeIcon aria-hidden="true" />
{{ formatMessage(messages.clientOrServer) }}
</template>
<template v-else-if="clientSide === 'required' && serverSide === 'required'">
<GlobeIcon aria-hidden="true" />
{{ formatMessage(messages.clientAndServer) }}
</template>
<template
v-else-if="
(clientSide === 'optional' || clientSide === 'required') &&
(serverSide === 'optional' || serverSide === 'unsupported')
"
>
<ClientIcon aria-hidden="true" />
{{ formatMessage(messages.client) }}
</template>
<template
v-else-if="
(serverSide === 'optional' || serverSide === 'required') &&
(clientSide === 'optional' || clientSide === 'unsupported')
"
>
<ServerIcon aria-hidden="true" />
{{ formatMessage(messages.server) }}
</template>
</TagItem>
</template>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { DownloadIcon, HeartIcon } from '@modrinth/assets'
import { capitalizeString, formatNumber } from '../../../../../utils'
import { useVIntl } from '../../../composables'
import { commonMessages } from '../../../utils'
const { formatMessage } = useVIntl()
defineProps<{
downloads?: number
followers?: number
}>()
</script>
<template>
<div
v-if="downloads !== undefined"
v-tooltip="
capitalizeString(
formatMessage(commonMessages.projectDownloads, {
count: formatNumber(downloads, false),
}),
)
"
class="flex items-center gap-2 trim-text-box smart-clickable:allow-pointer-events"
>
<DownloadIcon class="size-5 shrink-0" />
<span class="font-medium">
{{ formatNumber(downloads) }}
</span>
</div>
<div
v-if="followers !== undefined"
v-tooltip="
capitalizeString(
formatMessage(commonMessages.projectFollowers, {
count: formatNumber(followers, false),
}),
)
"
class="flex items-center gap-2 trim-text-box smart-clickable:allow-pointer-events"
>
<HeartIcon class="size-5 shrink-0" />
<span class="font-medium">
{{ formatNumber(followers) }}
</span>
</div>
</template>
<style scoped>
.trim-text-box {
text-box-trim: trim-both;
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue'
import { getTagMessage, sortTagsForDisplay } from '../../../utils'
import { TagTagItem } from '../../base'
import TagsOverflow from '../TagsOverflow.vue'
function isLoader(tag: string) {
return getTagMessage(tag, 'loader') !== undefined
}
const props = withDefaults(
defineProps<{
tags: string[]
extraTags?: string[]
deprioritizedTags?: string[]
excludeLoaders?: boolean
maxTags?: number
}>(),
{
maxTags: 5,
excludeLoaders: false,
extraTags: () => [],
deprioritizedTags: () => [],
},
)
const sortedTags = computed(() => (props.tags ? sortTagsForDisplay(props.tags) : undefined))
const sortedExtraTags = computed(() =>
props.extraTags ? sortTagsForDisplay(props.extraTags) : undefined,
)
const filteredTags = computed(() => {
if (!sortedTags.value) {
return undefined
}
return sortedTags.value.filter(
(tag) => !props.deprioritizedTags.includes(tag) && (!props.excludeLoaders || !isLoader(tag)),
)
})
const visibleTags = computed(() => filteredTags.value?.slice(0, props.maxTags))
const overflowTags = computed(() => [
...(props.tags.filter((x) => !visibleTags.value?.includes(x)) ?? []),
...(sortedExtraTags.value ?? []),
])
</script>
<template>
<TagTagItem
v-for="tag in visibleTags"
:key="'visible-tag-' + tag"
hide-non-loader-icon
:tag="tag"
/>
<TagsOverflow
v-if="overflowTags"
:tags="overflowTags"
class="smart-clickable:allow-pointer-events"
/>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
defineProps<{
title: string
compact?: boolean
}>()
</script>
<template>
<!-- the weird padding and negative margin are to include the potential hover underline in bounding box which affects rendering on firefox -->
<span
class="project-card-title line-clamp-1 pb-[2px] mb-[-2px] break-all font-semibold text-contrast m-0 leading-none smart-clickable:underline-on-hover"
:class="compact ? 'text-lg' : 'text-xl'"
>
{{ title }}
</span>
</template>
<style scoped>
@container (width < 550px) {
.project-card-title {
@apply text-base;
}
}
</style>

View File

@@ -2,8 +2,9 @@
export * from './settings'
// Other
export { default as NewProjectCard } from './NewProjectCard.vue'
export { default as ProjectCard } from './card/ProjectCard.vue'
export { default as ProjectBackgroundGradient } from './ProjectBackgroundGradient.vue'
export { default as ProjectCardList } from './ProjectCardList.vue'
export { default as ProjectHeader } from './ProjectHeader.vue'
export { default as ProjectPageDescription } from './ProjectPageDescription.vue'
export { default as ProjectPageVersions } from './ProjectPageVersions.vue'
@@ -12,3 +13,4 @@ export { default as ProjectSidebarCreators } from './ProjectSidebarCreators.vue'
export { default as ProjectSidebarDetails } from './ProjectSidebarDetails.vue'
export { default as ProjectSidebarLinks } from './ProjectSidebarLinks.vue'
export { default as ProjectStatusBadge } from './ProjectStatusBadge.vue'
export { default as TagsOverflow } from './TagsOverflow.vue'