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,10 +1,25 @@
<template>
<router-link v-if="to?.path || to?.query || to?.startsWith('/')" :to="to" v-bind="$attrs">
<router-link
v-if="
(typeof to === 'object' && (to?.path || to?.query)) ||
(typeof to === 'string' && to?.startsWith('/'))
"
:to="to"
v-bind="$attrs"
>
<slot />
</router-link>
<a v-else-if="to?.startsWith('http')" :href="to" v-bind="$attrs">
<a v-else-if="typeof to === 'string' && to?.startsWith('http')" :href="to" v-bind="$attrs">
<slot />
</a>
<button
v-else-if="typeof to === 'function'"
v-bind="$attrs"
class="inline bg-transparent border-none p-0 m-0 cursor-pointer"
@click="to()"
>
<slot />
</button>
<span v-else v-bind="$attrs">
<slot />
</span>

View File

@@ -138,14 +138,17 @@ function hash(str) {
<style lang="scss" scoped>
.avatar {
@apply min-w-[--_size] min-h-[--_size] w-[--_size] h-[--_size];
--_size: 2rem;
border: 1px solid var(--color-button-border);
border: 1px solid var(--surface-5);
background-color: var(--color-button-bg);
object-fit: contain;
border-radius: calc(16 / 96 * var(--_size));
border-radius: calc(16 / 96 * var(--_override-size, var(--_size)));
position: relative;
height: var(--_override-size, var(--_size));
width: var(--_override-size, var(--_size));
min-height: var(--_override-size, var(--_size));
min-width: var(--_override-size, var(--_size));
&.circle {
border-radius: 50%;

View File

@@ -72,7 +72,7 @@ function toggleItem(item: T) {
}
&:focus-visible {
outline: 0.25rem solid #ea80ff;
outline: 0.25rem solid var(--color-focus-ring);
border-radius: 0.25rem;
}
}

View File

@@ -86,7 +86,7 @@ label {
flex-direction: unset;
max-height: unset;
&:focus-within {
outline: 0.25rem solid #ea80ff;
outline: 0.25rem solid var(--color-focus-ring);
}
svg {

View File

@@ -1,569 +0,0 @@
<template>
<article class="project-card base-card" :aria-label="name" role="listitem">
<router-link class="icon" tabindex="-1" :to="`/${projectTypeUrl}/${id}`">
<Avatar :src="iconUrl" :alt="name" size="md" no-shadow loading="lazy" />
</router-link>
<router-link
class="gallery"
:class="{ 'no-image': !featuredImage }"
tabindex="-1"
:to="`/${projectTypeUrl}/${id}`"
:style="color ? `background-color: ${toColor};` : ''"
>
<img v-if="featuredImage" :src="featuredImage" alt="gallery image" loading="lazy" />
</router-link>
<div class="title">
<router-link :to="`/${projectTypeUrl}/${id}`">
<h2 class="name">
{{ name }}
</h2>
</router-link>
<p v-if="author" class="author">
by
<router-link class="title-link" :to="'/user/' + author">{{ author }} </router-link>
</p>
<Badge v-if="status && status !== 'approved'" :type="status" class="status" />
</div>
<p class="description">
{{ description }}
</p>
<Categories :categories="categories" :type="type" class="tags">
<EnvironmentIndicator
:type-only="moderation"
:client-side="clientSide"
:server-side="serverSide"
:type="projectTypeDisplay"
:search="search"
:categories="categories"
/>
</Categories>
<div class="stats">
<div v-if="downloads" class="stat">
<DownloadIcon aria-hidden="true" />
<p>
<strong>{{ formatNumber(downloads) }}</strong
><span class="stat-label"> download<span v-if="downloads !== '1'">s</span></span>
</p>
</div>
<div v-if="follows" class="stat">
<HeartIcon aria-hidden="true" />
<p>
<strong>{{ formatNumber(follows) }}</strong
><span class="stat-label"> follower<span v-if="follows !== '1'">s</span></span>
</p>
</div>
<div class="buttons">
<slot />
</div>
<div v-if="showUpdatedDate" v-tooltip="updatedDate" class="stat date">
<EditIcon aria-hidden="true" />
<span class="date-label">Updated </span> {{ sinceUpdated }}
</div>
<div v-else v-tooltip="createdDate" class="stat date">
<CalendarIcon aria-hidden="true" />
<span class="date-label">Published </span>{{ sinceCreation }}
</div>
</div>
</article>
</template>
<script setup>
import { CalendarIcon, DownloadIcon, EditIcon, HeartIcon } from '@modrinth/assets'
import { Avatar, Categories } from '@modrinth/ui'
import { formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime.js'
import { computed } from 'vue'
import { useRelativeTime } from '../../composables'
import EnvironmentIndicator from './EnvironmentIndicator.vue'
import Badge from './SimpleBadge.vue'
dayjs.extend(relativeTime)
const props = defineProps({
id: {
type: String,
default: 'modrinth-0',
},
type: {
type: String,
default: 'mod',
},
name: {
type: String,
default: 'Project Name',
},
author: {
type: String,
default: null,
},
description: {
type: String,
default: 'A _type description',
},
iconUrl: {
type: String,
default: '#',
required: false,
},
downloads: {
type: String,
default: null,
required: false,
},
follows: {
type: String,
default: null,
required: false,
},
createdAt: {
type: String,
default: '0000-00-00',
},
updatedAt: {
type: String,
default: null,
},
categories: {
type: Array,
default() {
return []
},
},
filteredCategories: {
type: Array,
default() {
return []
},
},
projectTypeDisplay: {
type: String,
default: null,
},
projectTypeUrl: {
type: String,
default: null,
},
status: {
type: String,
default: null,
},
serverSide: {
type: String,
required: false,
default: '',
},
clientSide: {
type: String,
required: false,
default: '',
},
moderation: {
type: Boolean,
required: false,
default: false,
},
search: {
type: Boolean,
required: false,
default: false,
},
featuredImage: {
type: String,
required: false,
default: null,
},
showUpdatedDate: {
type: Boolean,
required: false,
default: true,
},
hideLoaders: {
type: Boolean,
required: false,
default: false,
},
color: {
type: Number,
required: false,
default: null,
},
})
const formatRelativeTime = useRelativeTime()
const toColor = computed(() => {
if (props.color == null) return ''
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(',')})`
})
const createdDate = computed(() => dayjs(props.createdAt).format('MMMM D, YYYY [at] h:mm:ss A'))
const sinceCreation = computed(() => formatRelativeTime(props.createdAt))
const updatedDate = computed(() => dayjs(props.updatedAt).format('MMMM D, YYYY [at] h:mm:ss A'))
const sinceUpdated = computed(() => formatRelativeTime(props.updatedAt))
</script>
<style lang="scss" scoped>
.project-card {
display: inline-grid;
box-sizing: border-box;
overflow: hidden;
margin: 0;
line-height: 1;
}
.display-mode--list .project-card {
grid-template:
'icon title stats'
'icon description stats'
'icon tags stats';
grid-template-columns: min-content 1fr auto;
grid-template-rows: min-content 1fr min-content;
column-gap: var(--gap-md);
row-gap: var(--gap-sm);
width: 100%;
@media screen and (max-width: 750px) {
grid-template:
'icon title'
'icon description'
'icon tags'
'stats stats';
grid-template-columns: min-content auto;
grid-template-rows: min-content 1fr min-content min-content;
}
@media screen and (max-width: 550px) {
grid-template:
'icon title'
'icon description'
'tags tags'
'stats stats';
grid-template-columns: min-content auto;
grid-template-rows: min-content 1fr min-content min-content;
}
h2 {
margin: 0;
font-size: 1.5rem;
}
}
.display-mode--gallery .project-card,
.display-mode--grid .project-card {
padding: 0 0 1rem 0;
grid-template: 'gallery gallery' 'icon title' 'description description' 'tags tags' 'stats stats';
grid-template-columns: min-content 1fr;
grid-template-rows: min-content min-content 1fr min-content min-content;
row-gap: var(--gap-sm);
.gallery {
display: inline-block;
width: 100%;
height: 10rem;
background-color: var(--color-button-bg);
&.no-image {
filter: brightness(0.7);
}
img {
box-shadow: none;
width: 100%;
height: 10rem;
object-fit: cover;
}
}
.icon {
margin-left: var(--gap-lg);
margin-top: -3rem;
z-index: 1;
img,
svg {
border-radius: var(--radius-lg);
box-shadow:
-2px -2px 0 2px var(--color-raised-bg),
2px -2px 0 2px var(--color-raised-bg);
}
}
.title {
margin-left: var(--gap-md);
margin-right: var(--gap-md);
flex-direction: column;
.name {
font-size: 1.25rem;
}
.status {
margin-top: var(--gap-xs);
}
}
.description {
margin-inline: var(--gap-lg);
}
.tags {
margin-inline: var(--gap-lg);
}
.stats {
margin-inline: var(--gap-lg);
flex-direction: row;
align-items: center;
.stat-label {
display: none;
}
.buttons {
flex-direction: row;
gap: var(--gap-sm);
align-items: center;
> :first-child {
margin-left: auto;
}
&:first-child > :last-child {
margin-right: auto;
}
}
.buttons:not(:empty) + .date {
flex-basis: 100%;
}
}
}
.display-mode--grid .project-card {
.gallery {
display: none;
}
.icon {
margin-top: calc(var(--gap-lg) - var(--gap-sm));
img,
svg {
border: none;
}
}
.title {
margin-top: calc(var(--gap-lg) - var(--gap-sm));
}
}
.icon {
grid-area: icon;
display: flex;
align-items: center;
}
.gallery {
display: none;
height: 10rem;
grid-area: gallery;
}
.title {
grid-area: title;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
column-gap: var(--gap-sm);
row-gap: 0;
word-wrap: anywhere;
h2 {
font-weight: bolder;
color: var(--color-contrast);
}
h2,
p {
margin: 0;
overflow-wrap: anywhere;
}
svg {
width: auto;
color: var(--color-special-orange);
height: 1.5rem;
margin-bottom: -0.25rem;
}
.title-link {
text-decoration: underline;
&:focus-visible,
&:hover {
color: var(--color-heading);
}
&:active {
color: var(--color-text-dark);
}
}
}
.stats {
grid-area: stats;
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--gap-md);
.stat {
display: flex;
flex-direction: row;
align-items: center;
width: fit-content;
gap: var(--gap-xs);
--stat-strong-size: 1.25rem;
strong {
font-size: var(--stat-strong-size);
}
p {
margin: 0;
}
svg {
height: var(--stat-strong-size);
width: var(--stat-strong-size);
}
}
.date {
margin-top: auto;
}
@media screen and (max-width: 750px) {
flex-direction: row;
column-gap: var(--gap-md);
margin-top: var(--gap-xs);
}
@media screen and (max-width: 600px) {
margin-top: 0;
.stat-label {
display: none;
}
}
}
.environment {
color: var(--color-text) !important;
font-weight: bold;
}
.description {
grid-area: description;
margin-block: 0;
display: flex;
justify-content: flex-start;
}
.tags {
grid-area: tags;
display: flex;
flex-direction: row;
@media screen and (max-width: 550px) {
margin-top: var(--gap-xs);
}
}
.buttons {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
align-items: flex-end;
flex-grow: 1;
}
.small-mode {
@media screen and (min-width: 750px) {
grid-template:
'icon title'
'icon description'
'icon tags'
'stats stats' !important;
grid-template-columns: min-content auto !important;
grid-template-rows: min-content 1fr min-content min-content !important;
.tags {
margin-top: var(--gap-xs) !important;
}
.stats {
flex-direction: row;
column-gap: var(--gap-md) !important;
margin-top: var(--gap-xs) !important;
.stat-label {
display: none !important;
}
}
}
}
.base-card {
padding: var(--gap-lg);
position: relative;
min-height: 2rem;
background-color: var(--color-raised-bg);
border-radius: var(--radius-lg);
outline: 2px solid transparent;
box-shadow: var(--shadow-card);
.card__overlay {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
align-items: flex-end;
grid-gap: 0.5rem;
z-index: 2;
}
&.warning {
border-left: 0.5rem solid var(--color-banner-side);
padding: 1.5rem;
line-height: 1.5;
background-color: var(--color-banner-bg);
color: var(--color-banner-text);
min-height: 0;
a {
/* Uses active color to increase contrast */
color: var(--color-blue);
text-decoration: underline;
}
}
&.moderation-card {
background-color: var(--color-banner-bg);
}
}
</style>

View File

@@ -39,22 +39,26 @@ defineOptions({
}
// When clickable is being hovered or focus-visible, give contents an effect
&:has(> *:first-child:hover, > *:first-child:focus-visible) .smart-clickable__contents {
filter: var(--hover-filter-weak);
:first-child:hover + .smart-clickable__contents,
:first-child:focus-visible + .smart-clickable__contents {
// Utility classes for contents
:deep(.smart-clickable\:underline-on-hover) {
text-decoration: underline;
}
// Utility classes for contents
:deep(.smart-clickable\:highlight-on-hover) {
filter: brightness(var(--hover-brightness, 1.25));
}
}
:first-child:focus-visible + .smart-clickable__contents {
// Utility classes for contents
:deep(.smart-clickable\:outline-on-focus) {
outline: 0.25rem solid var(--color-focus-ring);
}
}
// When clickable is being clicked, give contents an effect
&:has(> *:first-child:active) .smart-clickable__contents {
:first-child:active + .smart-clickable__contents {
scale: 0.97;
}
}

View File

@@ -1,15 +1,12 @@
<template>
<button
v-if="action"
class="bg-[--_bg-color,var(--color-button-bg)] px-2 py-1 leading-none rounded-full font-semibold text-sm inline-flex items-center gap-1 text-[--_color,var(--color-secondary)] [&>svg]:shrink-0 [&>svg]:h-4 [&>svg]:w-4 border-none transition-transform active:scale-[0.95] cursor-pointer hover:underline"
:class="[baseClass, 'transition-transform active:scale-[0.95] cursor-pointer hover:underline']"
@click="action"
>
<slot />
</button>
<div
v-else
class="bg-[--_bg-color,var(--color-button-bg)] px-2 py-1 leading-none rounded-full font-semibold text-sm inline-flex items-center gap-1 text-[--_color,var(--color-secondary)] [&>svg]:shrink-0 [&>svg]:h-4 [&>svg]:w-4"
>
<div v-else :class="baseClass">
<slot />
</div>
</template>
@@ -17,4 +14,7 @@
defineProps<{
action?: (event: MouseEvent) => void
}>()
const baseClass =
'bg-[--_bg-color,var(--color-button-bg)] text-nowrap border-[--_bg-color,var(--surface-5)] border-[1px] border-solid px-2 py-1 leading-none rounded-full font-normal text-sm inline-flex items-center gap-1 text-[--_color,var(--color-secondary)] [&>svg]:shrink-0 [&>svg]:h-4 [&>svg]:w-4'
</script>

View File

@@ -0,0 +1,31 @@
<template>
<TagItem :action="action" :style="isLoader ? `--_color: var(--color-platform-${tag})` : ''">
<component :is="icon" v-if="icon" />
<FormattedTag :tag="tag" />
</TagItem>
</template>
<script setup lang="ts">
import { getTagIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { getTagMessage } from '../../utils'
import FormattedTag from './FormattedTag.vue'
import TagItem from './TagItem.vue'
const props = withDefaults(
defineProps<{
tag: string
hideNonLoaderIcon?: boolean
action?: (event: MouseEvent) => void
}>(),
{
hideNonLoaderIcon: false,
action: undefined,
},
)
const icon = computed(() =>
props.hideNonLoaderIcon && !isLoader.value ? undefined : getTagIcon(props.tag),
)
const isLoader = computed(() => getTagMessage(props.tag, 'loader') !== undefined)
</script>

View File

@@ -49,7 +49,6 @@ export { default as PopoutMenu } from './PopoutMenu.vue'
export { default as PreviewSelectButton } from './PreviewSelectButton.vue'
export { default as ProgressBar } from './ProgressBar.vue'
export { default as ProgressSpinner } from './ProgressSpinner.vue'
export { default as ProjectCard } from './ProjectCard.vue'
export { default as RadialHeader } from './RadialHeader.vue'
export { default as RadioButtons } from './RadioButtons.vue'
export { default as ScrollablePanel } from './ScrollablePanel.vue'
@@ -61,6 +60,7 @@ export { default as SmartClickable } from './SmartClickable.vue'
export type { TableColumn } from './Table.vue'
export { default as Table } from './Table.vue'
export { default as TagItem } from './TagItem.vue'
export { default as TagTagItem } from './TagTagItem.vue'
export { default as Timeline } from './Timeline.vue'
export { default as Toggle } from './Toggle.vue'
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'