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:
@@ -467,14 +467,6 @@ kbd {
|
||||
@import '~/assets/styles/utils.scss';
|
||||
@import '~/assets/styles/components.scss';
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
[tabindex='0']:focus-visible,
|
||||
[type='button']:focus-visible {
|
||||
outline: 0.25rem solid #ea80ff;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
// OMORPHIA FIXES
|
||||
.card {
|
||||
outline-offset: -2px;
|
||||
|
||||
@@ -87,7 +87,7 @@ export default {
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 0.25rem solid #ea80ff;
|
||||
outline: 0.25rem solid var(--color-focus-ring);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,526 +0,0 @@
|
||||
<template>
|
||||
<article class="project-card base-card padding-bg" :aria-label="name" role="listitem">
|
||||
<nuxt-link
|
||||
:title="name"
|
||||
class="icon"
|
||||
tabindex="-1"
|
||||
:to="`/${$getProjectTypeForUrl(type, categories)}/${id}`"
|
||||
>
|
||||
<Avatar :src="iconUrl" :alt="name" size="md" no-shadow loading="lazy" />
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
class="gallery"
|
||||
:class="{ 'no-image': !featuredImage }"
|
||||
tabindex="-1"
|
||||
:to="`/${$getProjectTypeForUrl(type, categories)}/${id}`"
|
||||
:style="color ? `background-color: ${toColor};` : ''"
|
||||
>
|
||||
<img v-if="featuredImage" :src="featuredImage" alt="gallery image" loading="lazy" />
|
||||
</nuxt-link>
|
||||
<div class="title">
|
||||
<nuxt-link :to="`/${$getProjectTypeForUrl(type, categories)}/${id}`">
|
||||
<h2 class="name !text-2xl">
|
||||
{{ name }}
|
||||
</h2>
|
||||
</nuxt-link>
|
||||
<p v-if="author" class="author">
|
||||
by
|
||||
<nuxt-link class="title-link" :to="'/user/' + author">
|
||||
{{ author }}
|
||||
</nuxt-link>
|
||||
</p>
|
||||
<ProjectStatusBadge v-if="status && status !== 'approved'" :status="status" class="status" />
|
||||
</div>
|
||||
<p class="description">
|
||||
{{ description }}
|
||||
</p>
|
||||
<Categories
|
||||
:categories="
|
||||
categories.filter((x) => !hideLoaders || !tags.loaders.find((y) => y.name === x))
|
||||
"
|
||||
:type="type"
|
||||
class="tags"
|
||||
>
|
||||
<EnvironmentIndicator
|
||||
v-if="clientSide && serverSide"
|
||||
: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="$dayjs(updatedAt).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="stat date"
|
||||
>
|
||||
<UpdatedIcon aria-hidden="true" />
|
||||
<span class="date-label">Updated </span>{{ formatRelativeTime(updatedAt) }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showCreatedDate"
|
||||
v-tooltip="$dayjs(createdAt).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="stat date"
|
||||
>
|
||||
<CalendarIcon aria-hidden="true" />
|
||||
<span class="date-label">Published </span>{{ formatRelativeTime(createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { CalendarIcon, DownloadIcon, HeartIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import { Avatar, Categories, ProjectStatusBadge, useRelativeTime } from '@modrinth/ui'
|
||||
|
||||
import EnvironmentIndicator from '~/components/ui/EnvironmentIndicator.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ProjectStatusBadge,
|
||||
EnvironmentIndicator,
|
||||
Avatar,
|
||||
Categories,
|
||||
CalendarIcon,
|
||||
UpdatedIcon,
|
||||
DownloadIcon,
|
||||
HeartIcon,
|
||||
},
|
||||
props: {
|
||||
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 []
|
||||
},
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
hasModMessage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
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,
|
||||
},
|
||||
showCreatedDate: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
hideLoaders: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
color: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const tags = useGeneratedState()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
return { tags, formatRelativeTime }
|
||||
},
|
||||
computed: {
|
||||
projectTypeDisplay() {
|
||||
return this.$getProjectTypeForDisplay(this.type, this.categories)
|
||||
},
|
||||
toColor() {
|
||||
let color = this.color
|
||||
|
||||
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 lang="scss" scoped>
|
||||
.project-card {
|
||||
display: inline-grid;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.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(--spacing-card-md);
|
||||
row-gap: var(--spacing-card-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;
|
||||
}
|
||||
}
|
||||
|
||||
.display-mode--gallery .project-card,
|
||||
.display-mode--grid .project-card {
|
||||
padding: 0 0 var(--spacing-card-bg) 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(--spacing-card-sm);
|
||||
|
||||
.gallery {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 10rem;
|
||||
background-color: var(--color-button-bg-active);
|
||||
|
||||
&.no-image {
|
||||
filter: brightness(0.7);
|
||||
}
|
||||
|
||||
img {
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
height: 10rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-left: var(--spacing-card-bg);
|
||||
margin-top: -3rem;
|
||||
z-index: 1;
|
||||
|
||||
img,
|
||||
svg {
|
||||
border-radius: var(--size-rounded-lg);
|
||||
box-shadow:
|
||||
-2px -2px 0 2px var(--color-raised-bg),
|
||||
2px -2px 0 2px var(--color-raised-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-left: var(--spacing-card-md);
|
||||
margin-right: var(--spacing-card-bg);
|
||||
flex-direction: column;
|
||||
|
||||
.name {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: var(--spacing-card-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-inline: var(--spacing-card-bg);
|
||||
}
|
||||
|
||||
.tags {
|
||||
margin-inline: var(--spacing-card-bg);
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin-inline: var(--spacing-card-bg);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.stat-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-card-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(--spacing-card-bg) - var(--spacing-card-sm));
|
||||
|
||||
img,
|
||||
svg {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: calc(var(--spacing-card-bg) - var(--spacing-card-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(--spacing-card-sm);
|
||||
row-gap: 0;
|
||||
word-wrap: anywhere;
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: auto;
|
||||
color: var(--color-orange);
|
||||
height: 1.5rem;
|
||||
margin-bottom: -0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-area: stats;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-card-md);
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
gap: var(--spacing-card-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(--spacing-card-md);
|
||||
margin-top: var(--spacing-card-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;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tags {
|
||||
grid-area: tags;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@media screen and (max-width: 550px) {
|
||||
margin-top: var(--spacing-card-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-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(--spacing-card-xs) !important;
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex-direction: row;
|
||||
column-gap: var(--spacing-card-md) !important;
|
||||
margin-top: var(--spacing-card-xs) !important;
|
||||
|
||||
.stat-label {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -77,11 +77,17 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<NewProjectCard
|
||||
<ProjectCard
|
||||
v-if="!versionsError && !currentVersionError"
|
||||
class="!cursor-default !bg-bg !filter-none"
|
||||
:project="projectCardData"
|
||||
:categories="data.project?.categories || []"
|
||||
class="!bg-bg"
|
||||
:title="projectCardData.title"
|
||||
:icon-url="projectCardData.icon_url"
|
||||
:date-updated="projectCardData.date_modified"
|
||||
:followers="projectCardData.follows"
|
||||
:downloads="projectCardData.downloads"
|
||||
layout="list"
|
||||
:summary="projectCardData.description"
|
||||
:tags="data.project?.categories || []"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled color="brand">
|
||||
@@ -91,7 +97,7 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</NewProjectCard>
|
||||
</ProjectCard>
|
||||
</div>
|
||||
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
|
||||
<ButtonStyled>
|
||||
@@ -159,7 +165,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CompassIcon, InfoIcon, SettingsIcon, TransferIcon, UploadIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, NewProjectCard, useVIntl } from '@modrinth/ui'
|
||||
import { ButtonStyled, ProjectCard, useVIntl } from '@modrinth/ui'
|
||||
import type { Loaders } from '@modrinth/utils'
|
||||
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
@@ -29,8 +29,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
projectTypesPrimaryNav: false,
|
||||
enableMedalPromotion: true,
|
||||
hidePlusPromoInUserMenu: false,
|
||||
oldProjectCards: true,
|
||||
newProjectCards: false,
|
||||
projectBackground: false,
|
||||
searchBackground: false,
|
||||
advancedDebugInfo: false,
|
||||
@@ -43,6 +41,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
serverDiscovery: false,
|
||||
disablePrettyProjectUrlRedirects: false,
|
||||
hidePreviewBanner: false,
|
||||
showDiscoverProjectButtons: false,
|
||||
} as const)
|
||||
|
||||
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||
|
||||
@@ -300,42 +300,48 @@
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => b.downloads - a.downloads)"
|
||||
:id="project.id"
|
||||
:key="project.id"
|
||||
:type="project.project_type"
|
||||
:categories="project.categories"
|
||||
:created-at="project.published"
|
||||
:updated-at="project.updated"
|
||||
:description="project.description"
|
||||
:downloads="project.downloads ? project.downloads.toString() : '0'"
|
||||
:follows="project.followers ? project.followers.toString() : '0'"
|
||||
:featured-image="project.gallery.find((element) => element.featured)?.url"
|
||||
:link="`/${project.project_type}/${project.slug ?? project.id}`"
|
||||
:title="project.title"
|
||||
:icon-url="project.icon_url"
|
||||
:name="project.title"
|
||||
:client-side="project.client_side"
|
||||
:server-side="project.server_side"
|
||||
:banner="project.gallery.find((element) => element.featured)?.url"
|
||||
:summary="project.description"
|
||||
:date-updated="project.updated"
|
||||
:downloads="project.downloads ?? 0"
|
||||
:followers="project.followers ?? 0"
|
||||
:tags="project.categories"
|
||||
:environment="{
|
||||
clientSide: project.client_side,
|
||||
serverSide: project.server_side,
|
||||
}"
|
||||
:color="project.color"
|
||||
:show-updated-date="!canEdit && collection.id !== 'following'"
|
||||
:show-created-date="!canEdit && collection.id !== 'following'"
|
||||
:layout="
|
||||
cosmetics.searchDisplayMode.collection === 'grid' ||
|
||||
cosmetics.searchDisplayMode.collection === 'gallery'
|
||||
? 'grid'
|
||||
: 'list'
|
||||
"
|
||||
>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="iconified-button remove-btn"
|
||||
:disabled="removing"
|
||||
@click="() => removeProject(project)"
|
||||
>
|
||||
<SpinnerIcon v-if="removing" class="animate-spin" aria-hidden="true" />
|
||||
<XIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.removeProjectButton) }}
|
||||
</button>
|
||||
<button
|
||||
v-if="collection.id === 'following'"
|
||||
class="iconified-button"
|
||||
@click="unfollowProject(project)"
|
||||
>
|
||||
<HeartMinusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.unfollowProjectButton) }}
|
||||
</button>
|
||||
<template v-if="canEdit || collection.id === 'following'" #actions>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="iconified-button remove-btn"
|
||||
:disabled="removing"
|
||||
@click="() => removeProject(project)"
|
||||
>
|
||||
<SpinnerIcon v-if="removing" class="animate-spin" aria-hidden="true" />
|
||||
<XIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.removeProjectButton) }}
|
||||
</button>
|
||||
<button
|
||||
v-if="collection.id === 'following'"
|
||||
class="iconified-button"
|
||||
@click="unfollowProject(project)"
|
||||
>
|
||||
<HeartMinusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.unfollowProjectButton) }}
|
||||
</button>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
</div>
|
||||
<div v-else>
|
||||
@@ -395,6 +401,7 @@ import {
|
||||
normalizeChildren,
|
||||
NormalPage,
|
||||
OverflowMenu,
|
||||
ProjectCard,
|
||||
RadioButtons,
|
||||
SidebarCard,
|
||||
useRelativeTime,
|
||||
@@ -406,7 +413,6 @@ import dayjs from 'dayjs'
|
||||
|
||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
||||
import { asEncodedJsonArray, fetchSegmented } from '~/utils/fetch-helpers.ts'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
@@ -1,41 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
BookmarkIcon,
|
||||
CheckIcon,
|
||||
DownloadIcon,
|
||||
FilterIcon,
|
||||
GameIcon,
|
||||
GridIcon,
|
||||
HeartIcon,
|
||||
ImageIcon,
|
||||
InfoIcon,
|
||||
LeftArrowIcon,
|
||||
ListIcon,
|
||||
MoreVerticalIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
DropdownSelect,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NewProjectCard,
|
||||
Pagination,
|
||||
ProjectCard,
|
||||
ProjectCardList,
|
||||
SearchFilterControl,
|
||||
SearchSidebarFilter,
|
||||
type SortType,
|
||||
Toggle,
|
||||
useSearch,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import { computed, type Reactive, watch } from 'vue'
|
||||
|
||||
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
|
||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
||||
import { projectQueryOptions } from '~/composables/queries/project'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
|
||||
import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
|
||||
@@ -53,6 +60,22 @@ const flags = useFeatureFlags()
|
||||
const auth = await useAuth()
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const modrinthClient = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
let prefetchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function handleProjectHover(result: Labrinth.Search.v2.ResultSearchProject) {
|
||||
if (prefetchTimeout) clearTimeout(prefetchTimeout)
|
||||
prefetchTimeout = setTimeout(() => {
|
||||
const slug = result.slug || result.project_id
|
||||
queryClient.prefetchQuery(projectQueryOptions.v2(slug, modrinthClient))
|
||||
queryClient.prefetchQuery(projectQueryOptions.v3(result.project_id, modrinthClient))
|
||||
queryClient.prefetchQuery(projectQueryOptions.members(result.project_id, modrinthClient))
|
||||
queryClient.prefetchQuery(projectQueryOptions.dependencies(result.project_id, modrinthClient))
|
||||
queryClient.prefetchQuery(projectQueryOptions.versionsV3(result.project_id, modrinthClient))
|
||||
}, 150)
|
||||
}
|
||||
|
||||
const currentType = computed(() =>
|
||||
queryAsStringOrEmpty(route.params.type).replaceAll(/^\/|s\/?$/g, ''),
|
||||
@@ -177,6 +200,14 @@ const currentMaxResultsOptions = computed(
|
||||
() => maxResultsForView.value[resultsDisplayMode.value] ?? [20],
|
||||
)
|
||||
|
||||
const LOADER_FILTER_TYPES = [
|
||||
'mod_loader',
|
||||
'plugin_loader',
|
||||
'modpack_loader',
|
||||
'shader_loader',
|
||||
'plugin_platform',
|
||||
] as const
|
||||
|
||||
const {
|
||||
// Selections
|
||||
query,
|
||||
@@ -198,6 +229,34 @@ const {
|
||||
createPageParams,
|
||||
} = useSearch(projectTypes, tags, serverFilters)
|
||||
|
||||
const selectedFilterTags = computed(() =>
|
||||
currentFilters.value
|
||||
.filter(
|
||||
(f) =>
|
||||
f.type.startsWith('category_') ||
|
||||
LOADER_FILTER_TYPES.includes(f.type as (typeof LOADER_FILTER_TYPES)[number]),
|
||||
)
|
||||
.map((f) => f.option),
|
||||
)
|
||||
const excludeLoaders = computed(
|
||||
() =>
|
||||
currentFilters.value.some((f) =>
|
||||
LOADER_FILTER_TYPES.includes(f.type as (typeof LOADER_FILTER_TYPES)[number]),
|
||||
) || ['resourcepack', 'datapack'].includes(currentType.value),
|
||||
)
|
||||
|
||||
const loadersNotForThisType = computed(() => {
|
||||
return (
|
||||
tags.value?.loaders
|
||||
?.filter((loader) => !loader.supported_project_types.includes(currentType.value))
|
||||
?.map((loader) => loader.name) ?? []
|
||||
)
|
||||
})
|
||||
|
||||
const deprioritizedTags = computed(() => {
|
||||
return [...selectedFilterTags.value, ...loadersNotForThisType.value]
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
gameVersionProvidedByServer: {
|
||||
id: 'search.filter.locked.server-game-version.title',
|
||||
@@ -353,7 +412,7 @@ function cycleSearchDisplayMode() {
|
||||
}
|
||||
cosmetics.value.searchDisplayMode[resultsDisplayLocation.value] = cycleValue(
|
||||
cosmetics.value.searchDisplayMode[resultsDisplayLocation.value],
|
||||
tags.value.projectViewModes,
|
||||
tags.value.projectViewModes.filter((x) => x !== 'grid'),
|
||||
)
|
||||
setClosestMaxResults()
|
||||
}
|
||||
@@ -611,84 +670,96 @@ useSeoMeta({
|
||||
<p>No results found for your query!</p>
|
||||
</div>
|
||||
<div v-else class="search-results-container">
|
||||
<div
|
||||
id="search-results"
|
||||
class="project-list"
|
||||
:class="'display-mode--' + resultsDisplayMode"
|
||||
role="list"
|
||||
<ProjectCardList
|
||||
aria-label="Search results"
|
||||
:layout="
|
||||
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
|
||||
"
|
||||
>
|
||||
<template v-for="result in results?.hits" :key="result.project_id">
|
||||
<ProjectCard
|
||||
v-if="flags.oldProjectCards"
|
||||
:id="result.slug ? result.slug : result.project_id"
|
||||
:display="resultsDisplayMode"
|
||||
:featured-image="
|
||||
result.featured_gallery ? result.featured_gallery : result.gallery[0]
|
||||
"
|
||||
:type="result.project_type"
|
||||
:author="result.author"
|
||||
:name="result.title"
|
||||
:description="result.description"
|
||||
:created-at="result.date_created"
|
||||
:updated-at="result.date_modified"
|
||||
:downloads="result.downloads.toString()"
|
||||
:follows="result.follows.toString()"
|
||||
:icon-url="result.icon_url"
|
||||
:client-side="result.client_side"
|
||||
:server-side="result.server_side"
|
||||
:categories="result.display_categories"
|
||||
:search="true"
|
||||
:show-updated-date="!server && currentSortType.name !== 'newest'"
|
||||
:show-created-date="!server"
|
||||
:hide-loaders="
|
||||
projectType ? ['resourcepack', 'datapack'].includes(projectType.id) : false
|
||||
"
|
||||
:color="result.color ?? undefined"
|
||||
>
|
||||
<template v-if="server">
|
||||
<button
|
||||
v-if="
|
||||
(result as InstallableSearchResult).installed ||
|
||||
(server?.content?.data &&
|
||||
server.content.data.find(
|
||||
(x: InstallableMod) => x.project_id === result.project_id,
|
||||
)) ||
|
||||
server.general?.project?.id === result.project_id
|
||||
"
|
||||
disabled
|
||||
class="btn btn-outline btn-primary"
|
||||
>
|
||||
<CheckIcon />
|
||||
Installed
|
||||
</button>
|
||||
<button
|
||||
v-else-if="(result as InstallableSearchResult).installing"
|
||||
disabled
|
||||
class="btn btn-outline btn-primary"
|
||||
>
|
||||
Installing...
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-outline btn-primary"
|
||||
@click="serverInstall(result as InstallableSearchResult)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
Install
|
||||
</button>
|
||||
<ProjectCard
|
||||
v-for="result in results?.hits"
|
||||
:key="result.project_id"
|
||||
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
|
||||
:title="result.title"
|
||||
:icon-url="result.icon_url"
|
||||
:author="{ name: result.author, link: `/user/${result.author}` }"
|
||||
:date-updated="result.date_modified"
|
||||
:date-published="result.date_created"
|
||||
:displayed-date="currentSortType.name === 'newest' ? 'published' : 'updated'"
|
||||
:downloads="result.downloads"
|
||||
:summary="result.description"
|
||||
:tags="result.display_categories"
|
||||
:all-tags="result.categories"
|
||||
:deprioritized-tags="deprioritizedTags"
|
||||
:exclude-loaders="excludeLoaders"
|
||||
:followers="result.follows"
|
||||
:banner="result.featured_gallery ?? undefined"
|
||||
:color="result.color ?? undefined"
|
||||
:environment="
|
||||
['mod', 'modpack'].includes(currentType)
|
||||
? {
|
||||
clientSide: result.client_side,
|
||||
serverSide: result.server_side,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:layout="
|
||||
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
|
||||
"
|
||||
@hover="handleProjectHover(result)"
|
||||
>
|
||||
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
|
||||
<template v-if="flags.showDiscoverProjectButtons">
|
||||
<ButtonStyled color="brand">
|
||||
<button>
|
||||
<DownloadIcon />
|
||||
Download
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button>
|
||||
<HeartIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button>
|
||||
<BookmarkIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button>
|
||||
<MoreVerticalIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
<NuxtLink
|
||||
v-if="flags.newProjectCards"
|
||||
:to="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
|
||||
>
|
||||
<NewProjectCard :project="result" :categories="result.display_categories">
|
||||
<template v-if="false" #actions></template>
|
||||
</NewProjectCard>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
<template v-else-if="server">
|
||||
<ButtonStyled color="brand" type="outlined">
|
||||
<button
|
||||
v-if="
|
||||
(result as InstallableSearchResult).installed ||
|
||||
(server?.content?.data &&
|
||||
server.content.data.find(
|
||||
(x: InstallableMod) => x.project_id === result.project_id,
|
||||
)) ||
|
||||
server.general?.project?.id === result.project_id
|
||||
"
|
||||
disabled
|
||||
>
|
||||
<CheckIcon />
|
||||
Installed
|
||||
</button>
|
||||
<button v-else-if="(result as InstallableSearchResult).installing" disabled>
|
||||
Installing...
|
||||
</button>
|
||||
<button v-else @click="serverInstall(result as InstallableSearchResult)">
|
||||
<DownloadIcon />
|
||||
Install
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
</ProjectCardList>
|
||||
</div>
|
||||
<div class="pagination-after">
|
||||
<pagination
|
||||
@@ -845,10 +916,6 @@ useSeoMeta({
|
||||
margin: 2rem;
|
||||
}
|
||||
|
||||
#search-results {
|
||||
min-height: 20vh;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 750px) {
|
||||
.search-controls {
|
||||
flex-wrap: nowrap;
|
||||
|
||||
@@ -125,24 +125,25 @@
|
||||
<div class="results display-mode--list">
|
||||
<ProjectCard
|
||||
v-for="result in searchProjects"
|
||||
:id="result.slug ? result.slug : result.project_id"
|
||||
:key="result.project_id"
|
||||
class="small-mode gradient-border"
|
||||
:type="result.project_type"
|
||||
:author="result.author"
|
||||
:name="result.title"
|
||||
:description="result.description"
|
||||
:created-at="result.date_created"
|
||||
:updated-at="result.date_modified"
|
||||
:downloads="result.downloads.toString()"
|
||||
:follows="result.follows.toString()"
|
||||
class="gradient-border"
|
||||
:link="`/${result.project_type}/${result.slug ? result.slug : result.project_id}`"
|
||||
:title="result.title"
|
||||
:author="{ name: result.author, link: `/user/${result.author}` }"
|
||||
:summary="result.description"
|
||||
:date-updated="result.date_modified"
|
||||
:date-published="result.date_created"
|
||||
:displayed-date="sortType === 'newest' ? 'published' : 'updated'"
|
||||
:downloads="result.downloads"
|
||||
:followers="result.follows"
|
||||
:icon-url="result.icon_url"
|
||||
:client-side="result.client_side"
|
||||
:server-side="result.server_side"
|
||||
:categories="result.display_categories.slice(0, 3)"
|
||||
:search="true"
|
||||
:show-updated-date="true"
|
||||
:environment="{
|
||||
clientSide: result.client_side,
|
||||
serverSide: result.server_side,
|
||||
}"
|
||||
:tags="result.display_categories.slice(0, 3)"
|
||||
:color="result.color"
|
||||
layout="list"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,6 +446,7 @@ import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
ProjectCard,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
@@ -454,7 +456,6 @@ import { Multiselect } from 'vue-multiselect'
|
||||
import ATLauncherLogo from '~/assets/images/external/atlauncher.svg?component'
|
||||
import PrismLauncherLogo from '~/assets/images/external/prism.svg?component'
|
||||
import LatestNewsRow from '~/components/ui/news/LatestNewsRow.vue'
|
||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
||||
import { homePageNotifs, homePageProjects, homePageSearch } from '~/generated/state.json'
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
@@ -204,29 +204,28 @@
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => b.downloads - a.downloads)"
|
||||
:id="project.slug || project.id"
|
||||
:key="project.id"
|
||||
:name="project.name"
|
||||
:display="cosmetics.searchDisplayMode.user"
|
||||
:featured-image="project.gallery.find((element) => element.featured)?.url"
|
||||
project-type-url="project"
|
||||
:description="project.summary"
|
||||
:created-at="project.published"
|
||||
:updated-at="project.updated"
|
||||
:downloads="project.downloads.toString()"
|
||||
:follows="project.followers.toString()"
|
||||
:link="`/${project.project_types[0] ?? 'project'}/${project.slug || project.id}`"
|
||||
:title="project.name"
|
||||
:icon-url="project.icon_url"
|
||||
:categories="project.categories"
|
||||
:client-side="project.client_side"
|
||||
:server-side="project.server_side"
|
||||
:banner="project.gallery.find((element) => element.featured)?.url"
|
||||
:summary="project.summary"
|
||||
:date-updated="project.updated"
|
||||
:downloads="project.downloads"
|
||||
:followers="project.followers"
|
||||
:tags="project.categories"
|
||||
:environment="{
|
||||
clientSide: project.client_side,
|
||||
serverSide: project.server_side,
|
||||
}"
|
||||
:status="
|
||||
auth.user &&
|
||||
(auth.user.id! === (user as any).id || tags.staffRoles.includes(auth.user.role))
|
||||
? (project.status as ProjectStatus)
|
||||
: undefined
|
||||
"
|
||||
:type="project.project_types[0] ?? 'project'"
|
||||
:color="project.color"
|
||||
layout="list"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -267,6 +266,7 @@ import {
|
||||
commonMessages,
|
||||
ContentPageHeader,
|
||||
OverflowMenu,
|
||||
ProjectCard,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { Organization, ProjectStatus, ProjectType, ProjectV3 } from '@modrinth/utils'
|
||||
@@ -277,7 +277,6 @@ import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
||||
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
|
||||
import {
|
||||
OrganizationContext,
|
||||
|
||||
@@ -59,41 +59,17 @@
|
||||
class="radio shrink-0"
|
||||
/>
|
||||
<RadioButtonIcon v-else class="radio shrink-0" />
|
||||
Rows
|
||||
List
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="preview-radio button-base"
|
||||
:class="{
|
||||
selected: cosmetics.searchDisplayMode[projectType.id] === 'grid',
|
||||
selected:
|
||||
cosmetics.searchDisplayMode[projectType.id] === 'gallery' ||
|
||||
cosmetics.searchDisplayMode[projectType.id] === 'grid',
|
||||
}"
|
||||
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'grid')"
|
||||
>
|
||||
<div class="preview">
|
||||
<div class="layout-grid-mode">
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
<div class="example-card card"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonCheckedIcon
|
||||
v-if="cosmetics.searchDisplayMode[projectType.id] === 'grid'"
|
||||
class="radio shrink-0"
|
||||
/>
|
||||
<RadioButtonIcon v-else class="radio shrink-0" />
|
||||
Grid
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="preview-radio button-base"
|
||||
:class="{
|
||||
selected: cosmetics.searchDisplayMode[projectType.id] === 'gallery',
|
||||
}"
|
||||
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'gallery')"
|
||||
>
|
||||
<div class="preview">
|
||||
<div class="layout-gallery-mode">
|
||||
@@ -105,11 +81,14 @@
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonCheckedIcon
|
||||
v-if="cosmetics.searchDisplayMode[projectType.id] === 'gallery'"
|
||||
v-if="
|
||||
cosmetics.searchDisplayMode[projectType.id] === 'gallery' ||
|
||||
cosmetics.searchDisplayMode[projectType.id] === 'grid'
|
||||
"
|
||||
class="radio shrink-0"
|
||||
/>
|
||||
<RadioButtonIcon v-else class="radio shrink-0" />
|
||||
Gallery
|
||||
Grid
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -301,27 +301,32 @@
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => b.downloads - a.downloads)"
|
||||
:id="project.slug || project.id"
|
||||
:key="project.id"
|
||||
:name="project.title"
|
||||
:display="cosmetics.searchDisplayMode.user"
|
||||
:featured-image="project.gallery.find((element) => element.featured)?.url"
|
||||
:description="project.description"
|
||||
:created-at="project.published"
|
||||
:updated-at="project.updated"
|
||||
:downloads="project.downloads.toString()"
|
||||
:follows="project.followers.toString()"
|
||||
:link="`/${project.project_type ?? 'project'}/${project.slug ? project.slug : project.id}`"
|
||||
:title="project.title"
|
||||
:icon-url="project.icon_url"
|
||||
:categories="project.categories"
|
||||
:client-side="project.client_side"
|
||||
:server-side="project.server_side"
|
||||
:status="
|
||||
auth.user && (auth.user.id === user.id || tags.staffRoles.includes(auth.user.role))
|
||||
? project.status
|
||||
: null
|
||||
:date-updated="project.updated"
|
||||
:downloads="project.downloads"
|
||||
:summary="project.description"
|
||||
:tags="[...project.categories]"
|
||||
:all-tags="[
|
||||
...project.categories,
|
||||
...project.loaders,
|
||||
...project.additional_categories,
|
||||
]"
|
||||
:followers="project.followers"
|
||||
:color="project.color ?? undefined"
|
||||
:environment="{
|
||||
clientSide: project.client_side,
|
||||
serverSide: project.server_side,
|
||||
}"
|
||||
:layout="
|
||||
cosmetics.searchDisplayMode.user === 'grid' ||
|
||||
cosmetics.searchDisplayMode.user === 'gallery'
|
||||
? 'grid'
|
||||
: 'list'
|
||||
"
|
||||
:type="project.project_type"
|
||||
:color="project.color"
|
||||
:status="project.status"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -493,6 +498,7 @@ import {
|
||||
IntlFormatted,
|
||||
NewModal,
|
||||
OverflowMenu,
|
||||
ProjectCard,
|
||||
TagItem,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
@@ -511,7 +517,6 @@ import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
|
||||
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
|
||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
||||
import { reportUser } from '~/utils/report-helpers.ts'
|
||||
|
||||
const data = useNuxtApp()
|
||||
|
||||
Reference in New Issue
Block a user