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:
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
31
packages/ui/src/components/base/TagTagItem.vue
Normal file
31
packages/ui/src/components/base/TagTagItem.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
28
packages/ui/src/components/project/ProjectCardList.vue
Normal file
28
packages/ui/src/components/project/ProjectCardList.vue
Normal 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>
|
||||
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
|
||||
29
packages/ui/src/components/project/TagsOverflow.vue
Normal file
29
packages/ui/src/components/project/TagsOverflow.vue
Normal 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>
|
||||
355
packages/ui/src/components/project/card/ProjectCard.vue
Normal file
355
packages/ui/src/components/project/card/ProjectCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
47
packages/ui/src/components/project/card/ProjectCardDate.vue
Normal file
47
packages/ui/src/components/project/card/ProjectCardDate.vue
Normal 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>
|
||||
@@ -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>
|
||||
54
packages/ui/src/components/project/card/ProjectCardStats.vue
Normal file
54
packages/ui/src/components/project/card/ProjectCardStats.vue
Normal 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>
|
||||
60
packages/ui/src/components/project/card/ProjectCardTags.vue
Normal file
60
packages/ui/src/components/project/card/ProjectCardTags.vue
Normal 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>
|
||||
23
packages/ui/src/components/project/card/ProjectCardTitle.vue
Normal file
23
packages/ui/src/components/project/card/ProjectCardTitle.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user