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:
@@ -18,8 +18,8 @@ import { useRouter } from 'vue-router'
|
|||||||
|
|
||||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||||
import Instance from '@/components/ui/Instance.vue'
|
import Instance from '@/components/ui/Instance.vue'
|
||||||
|
import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
|
||||||
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
||||||
import ProjectCard from '@/components/ui/ProjectCard.vue'
|
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
import { get_by_profile_path } from '@/helpers/process.js'
|
import { get_by_profile_path } from '@/helpers/process.js'
|
||||||
import { duplicate, kill, remove, run } from '@/helpers/profile.js'
|
import { duplicate, kill, remove, run } from '@/helpers/profile.js'
|
||||||
@@ -270,7 +270,7 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<section v-else ref="modsRow" class="projects">
|
<section v-else ref="modsRow" class="projects">
|
||||||
<ProjectCard
|
<LegacyProjectCard
|
||||||
v-for="project in row.instances.slice(0, maxProjectsPerRow)"
|
v-for="project in row.instances.slice(0, maxProjectsPerRow)"
|
||||||
:key="project?.project_id"
|
:key="project?.project_id"
|
||||||
ref="instanceComponents"
|
ref="instanceComponents"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<ProjectCard
|
||||||
class="card-shadow p-4 bg-bg-raised rounded-xl flex gap-3 group cursor-pointer hover:brightness-90 transition-all"
|
:title="project.title"
|
||||||
@click="
|
:link="
|
||||||
() => {
|
() => {
|
||||||
emit('open')
|
emit('open')
|
||||||
$router.push({
|
$router.push({
|
||||||
@@ -10,116 +10,58 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
|
:author="{ name: project.author, link: `https://modrinth.com/user/${project.author}` }"
|
||||||
|
:icon-url="project.icon_url"
|
||||||
|
:summary="project.description"
|
||||||
|
:tags="project.display_categories"
|
||||||
|
:all-tags="project.categories"
|
||||||
|
:downloads="project.downloads"
|
||||||
|
:followers="project.follows"
|
||||||
|
:date-updated="project.date_modified"
|
||||||
|
:banner="project.featured_gallery ?? undefined"
|
||||||
|
:color="project.color ?? undefined"
|
||||||
|
:environment="
|
||||||
|
projectType
|
||||||
|
? ['mod', 'modpack'].includes(projectType)
|
||||||
|
? {
|
||||||
|
clientSide: project.client_side,
|
||||||
|
serverSide: project.server_side,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
layout="list"
|
||||||
>
|
>
|
||||||
<div class="icon w-[96px] h-[96px] relative">
|
<template #actions>
|
||||||
<Avatar :src="project.icon_url" size="96px" class="search-icon origin-top transition-all" />
|
<ButtonStyled color="brand" type="outlined">
|
||||||
</div>
|
<button
|
||||||
<div class="flex flex-col gap-2 overflow-hidden">
|
:disabled="installed || installing"
|
||||||
<div class="gap-2 overflow-hidden no-wrap text-ellipsis">
|
class="shrink-0 no-wrap"
|
||||||
<span class="text-lg font-extrabold text-contrast m-0 leading-none">
|
@click.stop="install()"
|
||||||
{{ 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 v-if="categories.length > 0" class="mt-auto flex items-center gap-1 no-wrap">
|
|
||||||
<TagsIcon class="h-4 w-4 shrink-0" />
|
|
||||||
<div
|
|
||||||
v-if="project.project_type === 'mod' || project.project_type === 'modpack'"
|
|
||||||
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
|
|
||||||
>
|
>
|
||||||
<template v-if="project.client_side === 'optional' && project.server_side === 'optional'">
|
<template v-if="!installed">
|
||||||
Client or server
|
<DownloadIcon v-if="modpack || instance" />
|
||||||
|
<PlusIcon v-else />
|
||||||
</template>
|
</template>
|
||||||
<template
|
<CheckIcon v-else />
|
||||||
v-else-if="
|
{{
|
||||||
(project.client_side === 'optional' || project.client_side === 'required') &&
|
installing
|
||||||
(project.server_side === 'optional' || project.server_side === 'unsupported')
|
? 'Installing'
|
||||||
"
|
: installed
|
||||||
>
|
? 'Installed'
|
||||||
Client
|
: modpack || instance
|
||||||
</template>
|
? 'Install'
|
||||||
<template
|
: 'Add to an instance'
|
||||||
v-else-if="
|
}}
|
||||||
(project.server_side === 'optional' || project.server_side === 'required') &&
|
</button>
|
||||||
(project.client_side === 'optional' || project.client_side === 'unsupported')
|
</ButtonStyled>
|
||||||
"
|
</template>
|
||||||
>
|
</ProjectCard>
|
||||||
Server
|
|
||||||
</template>
|
|
||||||
<template
|
|
||||||
v-else-if="
|
|
||||||
project.client_side === 'unsupported' && project.server_side === 'unsupported'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Unsupported
|
|
||||||
</template>
|
|
||||||
<template
|
|
||||||
v-else-if="project.client_side === 'required' && project.server_side === 'required'"
|
|
||||||
>
|
|
||||||
Client and server
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<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.name" />
|
|
||||||
</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="absolute bottom-0 right-0 w-fit">
|
|
||||||
<ButtonStyled color="brand" type="outlined">
|
|
||||||
<button
|
|
||||||
:disabled="installed || installing"
|
|
||||||
class="shrink-0 no-wrap"
|
|
||||||
@click.stop="install()"
|
|
||||||
>
|
|
||||||
<template v-if="!installed">
|
|
||||||
<DownloadIcon v-if="modpack || instance" />
|
|
||||||
<PlusIcon v-else />
|
|
||||||
</template>
|
|
||||||
<CheckIcon v-else />
|
|
||||||
{{
|
|
||||||
installing
|
|
||||||
? 'Installing'
|
|
||||||
: installed
|
|
||||||
? 'Installed'
|
|
||||||
: modpack || instance
|
|
||||||
? 'Install'
|
|
||||||
: 'Add to an instance'
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { CheckIcon, DownloadIcon, HeartIcon, PlusIcon, TagsIcon } from '@modrinth/assets'
|
import { CheckIcon, DownloadIcon, PlusIcon } from '@modrinth/assets'
|
||||||
import { Avatar, ButtonStyled, FormattedTag, injectNotificationManager } from '@modrinth/ui'
|
import { ButtonStyled, injectNotificationManager, ProjectCard } from '@modrinth/ui'
|
||||||
import { formatNumber } from '@modrinth/utils'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
@@ -141,10 +83,6 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
categories: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
instance: {
|
instance: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: null,
|
default: null,
|
||||||
@@ -157,6 +95,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
projectType: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['open', 'install'])
|
const emit = defineEmits(['open', 'install'])
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
ProjectCardList,
|
||||||
SearchFilterControl,
|
SearchFilterControl,
|
||||||
SearchSidebarFilter,
|
SearchSidebarFilter,
|
||||||
useSearch,
|
useSearch,
|
||||||
@@ -509,10 +510,12 @@ previousFilterState.value = JSON.stringify({
|
|||||||
<section v-else-if="offline && results.total_hits === 0" class="offline">
|
<section v-else-if="offline && results.total_hits === 0" class="offline">
|
||||||
You are currently offline. Connect to the internet to browse Modrinth!
|
You are currently offline. Connect to the internet to browse Modrinth!
|
||||||
</section>
|
</section>
|
||||||
<section v-else class="project-list display-mode--list instance-results" role="list">
|
|
||||||
|
<ProjectCardList v-else :layout="'list'">
|
||||||
<SearchCard
|
<SearchCard
|
||||||
v-for="result in results.hits"
|
v-for="result in results.hits"
|
||||||
:key="result?.project_id"
|
:key="result?.project_id"
|
||||||
|
:project-type="projectType"
|
||||||
:project="result"
|
:project="result"
|
||||||
:instance="instance"
|
:instance="instance"
|
||||||
:categories="[
|
:categories="[
|
||||||
@@ -538,7 +541,7 @@ previousFilterState.value = JSON.stringify({
|
|||||||
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
|
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
|
||||||
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
|
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</section>
|
</ProjectCardList>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<pagination
|
<pagination
|
||||||
:page="currentPage"
|
:page="currentPage"
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export default defineNuxtConfig({
|
|||||||
console.log('Tags generated!')
|
console.log('Tags generated!')
|
||||||
|
|
||||||
const robotsContent =
|
const robotsContent =
|
||||||
getDomain() === PROD_MODRINTH_URL
|
getDomain() === PROD_MODRINTH_URL && process.env.PREVIEW !== 'true'
|
||||||
? 'User-agent: *\nDisallow: /_internal/'
|
? 'User-agent: *\nDisallow: /_internal/'
|
||||||
: 'User-agent: *\nDisallow: /'
|
: 'User-agent: *\nDisallow: /'
|
||||||
|
|
||||||
|
|||||||
@@ -467,14 +467,6 @@ kbd {
|
|||||||
@import '~/assets/styles/utils.scss';
|
@import '~/assets/styles/utils.scss';
|
||||||
@import '~/assets/styles/components.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
|
// OMORPHIA FIXES
|
||||||
.card {
|
.card {
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 0.25rem solid #ea80ff;
|
outline: 0.25rem solid var(--color-focus-ring);
|
||||||
border-radius: 0.25rem;
|
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>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NewProjectCard
|
<ProjectCard
|
||||||
v-if="!versionsError && !currentVersionError"
|
v-if="!versionsError && !currentVersionError"
|
||||||
class="!cursor-default !bg-bg !filter-none"
|
class="!bg-bg"
|
||||||
:project="projectCardData"
|
:title="projectCardData.title"
|
||||||
:categories="data.project?.categories || []"
|
: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>
|
<template #actions>
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
@@ -91,7 +97,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</template>
|
</template>
|
||||||
</NewProjectCard>
|
</ProjectCard>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
|
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
|
||||||
<ButtonStyled>
|
<ButtonStyled>
|
||||||
@@ -159,7 +165,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CompassIcon, InfoIcon, SettingsIcon, TransferIcon, UploadIcon } from '@modrinth/assets'
|
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 { Loaders } from '@modrinth/utils'
|
||||||
|
|
||||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
|||||||
projectTypesPrimaryNav: false,
|
projectTypesPrimaryNav: false,
|
||||||
enableMedalPromotion: true,
|
enableMedalPromotion: true,
|
||||||
hidePlusPromoInUserMenu: false,
|
hidePlusPromoInUserMenu: false,
|
||||||
oldProjectCards: true,
|
|
||||||
newProjectCards: false,
|
|
||||||
projectBackground: false,
|
projectBackground: false,
|
||||||
searchBackground: false,
|
searchBackground: false,
|
||||||
advancedDebugInfo: false,
|
advancedDebugInfo: false,
|
||||||
@@ -43,6 +41,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
|||||||
serverDiscovery: false,
|
serverDiscovery: false,
|
||||||
disablePrettyProjectUrlRedirects: false,
|
disablePrettyProjectUrlRedirects: false,
|
||||||
hidePreviewBanner: false,
|
hidePreviewBanner: false,
|
||||||
|
showDiscoverProjectButtons: false,
|
||||||
} as const)
|
} as const)
|
||||||
|
|
||||||
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||||
|
|||||||
@@ -300,42 +300,48 @@
|
|||||||
)
|
)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => b.downloads - a.downloads)"
|
.sort((a, b) => b.downloads - a.downloads)"
|
||||||
:id="project.id"
|
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
:type="project.project_type"
|
:link="`/${project.project_type}/${project.slug ?? project.id}`"
|
||||||
:categories="project.categories"
|
:title="project.title"
|
||||||
: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"
|
|
||||||
:icon-url="project.icon_url"
|
:icon-url="project.icon_url"
|
||||||
:name="project.title"
|
:banner="project.gallery.find((element) => element.featured)?.url"
|
||||||
:client-side="project.client_side"
|
:summary="project.description"
|
||||||
:server-side="project.server_side"
|
: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"
|
:color="project.color"
|
||||||
:show-updated-date="!canEdit && collection.id !== 'following'"
|
:layout="
|
||||||
:show-created-date="!canEdit && collection.id !== 'following'"
|
cosmetics.searchDisplayMode.collection === 'grid' ||
|
||||||
|
cosmetics.searchDisplayMode.collection === 'gallery'
|
||||||
|
? 'grid'
|
||||||
|
: 'list'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<button
|
<template v-if="canEdit || collection.id === 'following'" #actions>
|
||||||
v-if="canEdit"
|
<button
|
||||||
class="iconified-button remove-btn"
|
v-if="canEdit"
|
||||||
:disabled="removing"
|
class="iconified-button remove-btn"
|
||||||
@click="() => removeProject(project)"
|
:disabled="removing"
|
||||||
>
|
@click="() => removeProject(project)"
|
||||||
<SpinnerIcon v-if="removing" class="animate-spin" aria-hidden="true" />
|
>
|
||||||
<XIcon v-else aria-hidden="true" />
|
<SpinnerIcon v-if="removing" class="animate-spin" aria-hidden="true" />
|
||||||
{{ formatMessage(messages.removeProjectButton) }}
|
<XIcon v-else aria-hidden="true" />
|
||||||
</button>
|
{{ formatMessage(messages.removeProjectButton) }}
|
||||||
<button
|
</button>
|
||||||
v-if="collection.id === 'following'"
|
<button
|
||||||
class="iconified-button"
|
v-if="collection.id === 'following'"
|
||||||
@click="unfollowProject(project)"
|
class="iconified-button"
|
||||||
>
|
@click="unfollowProject(project)"
|
||||||
<HeartMinusIcon aria-hidden="true" />
|
>
|
||||||
{{ formatMessage(messages.unfollowProjectButton) }}
|
<HeartMinusIcon aria-hidden="true" />
|
||||||
</button>
|
{{ formatMessage(messages.unfollowProjectButton) }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</ProjectCard>
|
</ProjectCard>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
@@ -395,6 +401,7 @@ import {
|
|||||||
normalizeChildren,
|
normalizeChildren,
|
||||||
NormalPage,
|
NormalPage,
|
||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
|
ProjectCard,
|
||||||
RadioButtons,
|
RadioButtons,
|
||||||
SidebarCard,
|
SidebarCard,
|
||||||
useRelativeTime,
|
useRelativeTime,
|
||||||
@@ -406,7 +413,6 @@ import dayjs from 'dayjs'
|
|||||||
|
|
||||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
|
||||||
import { asEncodedJsonArray, fetchSegmented } from '~/utils/fetch-helpers.ts'
|
import { asEncodedJsonArray, fetchSegmented } from '~/utils/fetch-helpers.ts'
|
||||||
|
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
|
|||||||
@@ -1,41 +1,48 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Labrinth } from '@modrinth/api-client'
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import {
|
import {
|
||||||
|
BookmarkIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
FilterIcon,
|
FilterIcon,
|
||||||
GameIcon,
|
GameIcon,
|
||||||
GridIcon,
|
GridIcon,
|
||||||
|
HeartIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
LeftArrowIcon,
|
LeftArrowIcon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
|
MoreVerticalIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
defineMessages,
|
||||||
DropdownSelect,
|
DropdownSelect,
|
||||||
|
injectModrinthClient,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
NewProjectCard,
|
|
||||||
Pagination,
|
Pagination,
|
||||||
|
ProjectCard,
|
||||||
|
ProjectCardList,
|
||||||
SearchFilterControl,
|
SearchFilterControl,
|
||||||
SearchSidebarFilter,
|
SearchSidebarFilter,
|
||||||
type SortType,
|
type SortType,
|
||||||
Toggle,
|
Toggle,
|
||||||
useSearch,
|
useSearch,
|
||||||
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
|
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import { useThrottleFn } from '@vueuse/core'
|
import { useThrottleFn } from '@vueuse/core'
|
||||||
import { computed, type Reactive, watch } from 'vue'
|
import { computed, type Reactive, watch } from 'vue'
|
||||||
|
|
||||||
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
|
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
|
||||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.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 type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||||
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
|
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
|
||||||
import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
|
import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
|
||||||
@@ -53,6 +60,22 @@ const flags = useFeatureFlags()
|
|||||||
const auth = await useAuth()
|
const auth = await useAuth()
|
||||||
|
|
||||||
const { handleError } = injectNotificationManager()
|
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(() =>
|
const currentType = computed(() =>
|
||||||
queryAsStringOrEmpty(route.params.type).replaceAll(/^\/|s\/?$/g, ''),
|
queryAsStringOrEmpty(route.params.type).replaceAll(/^\/|s\/?$/g, ''),
|
||||||
@@ -177,6 +200,14 @@ const currentMaxResultsOptions = computed(
|
|||||||
() => maxResultsForView.value[resultsDisplayMode.value] ?? [20],
|
() => maxResultsForView.value[resultsDisplayMode.value] ?? [20],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const LOADER_FILTER_TYPES = [
|
||||||
|
'mod_loader',
|
||||||
|
'plugin_loader',
|
||||||
|
'modpack_loader',
|
||||||
|
'shader_loader',
|
||||||
|
'plugin_platform',
|
||||||
|
] as const
|
||||||
|
|
||||||
const {
|
const {
|
||||||
// Selections
|
// Selections
|
||||||
query,
|
query,
|
||||||
@@ -198,6 +229,34 @@ const {
|
|||||||
createPageParams,
|
createPageParams,
|
||||||
} = useSearch(projectTypes, tags, serverFilters)
|
} = 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({
|
const messages = defineMessages({
|
||||||
gameVersionProvidedByServer: {
|
gameVersionProvidedByServer: {
|
||||||
id: 'search.filter.locked.server-game-version.title',
|
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] = cycleValue(
|
||||||
cosmetics.value.searchDisplayMode[resultsDisplayLocation.value],
|
cosmetics.value.searchDisplayMode[resultsDisplayLocation.value],
|
||||||
tags.value.projectViewModes,
|
tags.value.projectViewModes.filter((x) => x !== 'grid'),
|
||||||
)
|
)
|
||||||
setClosestMaxResults()
|
setClosestMaxResults()
|
||||||
}
|
}
|
||||||
@@ -611,84 +670,96 @@ useSeoMeta({
|
|||||||
<p>No results found for your query!</p>
|
<p>No results found for your query!</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="search-results-container">
|
<div v-else class="search-results-container">
|
||||||
<div
|
<ProjectCardList
|
||||||
id="search-results"
|
|
||||||
class="project-list"
|
|
||||||
:class="'display-mode--' + resultsDisplayMode"
|
|
||||||
role="list"
|
|
||||||
aria-label="Search results"
|
aria-label="Search results"
|
||||||
|
:layout="
|
||||||
|
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<template v-for="result in results?.hits" :key="result.project_id">
|
<ProjectCard
|
||||||
<ProjectCard
|
v-for="result in results?.hits"
|
||||||
v-if="flags.oldProjectCards"
|
:key="result.project_id"
|
||||||
:id="result.slug ? result.slug : result.project_id"
|
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
|
||||||
:display="resultsDisplayMode"
|
:title="result.title"
|
||||||
:featured-image="
|
:icon-url="result.icon_url"
|
||||||
result.featured_gallery ? result.featured_gallery : result.gallery[0]
|
:author="{ name: result.author, link: `/user/${result.author}` }"
|
||||||
"
|
:date-updated="result.date_modified"
|
||||||
:type="result.project_type"
|
:date-published="result.date_created"
|
||||||
:author="result.author"
|
:displayed-date="currentSortType.name === 'newest' ? 'published' : 'updated'"
|
||||||
:name="result.title"
|
:downloads="result.downloads"
|
||||||
:description="result.description"
|
:summary="result.description"
|
||||||
:created-at="result.date_created"
|
:tags="result.display_categories"
|
||||||
:updated-at="result.date_modified"
|
:all-tags="result.categories"
|
||||||
:downloads="result.downloads.toString()"
|
:deprioritized-tags="deprioritizedTags"
|
||||||
:follows="result.follows.toString()"
|
:exclude-loaders="excludeLoaders"
|
||||||
:icon-url="result.icon_url"
|
:followers="result.follows"
|
||||||
:client-side="result.client_side"
|
:banner="result.featured_gallery ?? undefined"
|
||||||
:server-side="result.server_side"
|
:color="result.color ?? undefined"
|
||||||
:categories="result.display_categories"
|
:environment="
|
||||||
:search="true"
|
['mod', 'modpack'].includes(currentType)
|
||||||
:show-updated-date="!server && currentSortType.name !== 'newest'"
|
? {
|
||||||
:show-created-date="!server"
|
clientSide: result.client_side,
|
||||||
:hide-loaders="
|
serverSide: result.server_side,
|
||||||
projectType ? ['resourcepack', 'datapack'].includes(projectType.id) : false
|
}
|
||||||
"
|
: undefined
|
||||||
:color="result.color ?? undefined"
|
"
|
||||||
>
|
:layout="
|
||||||
<template v-if="server">
|
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
|
||||||
<button
|
"
|
||||||
v-if="
|
@hover="handleProjectHover(result)"
|
||||||
(result as InstallableSearchResult).installed ||
|
>
|
||||||
(server?.content?.data &&
|
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
|
||||||
server.content.data.find(
|
<template v-if="flags.showDiscoverProjectButtons">
|
||||||
(x: InstallableMod) => x.project_id === result.project_id,
|
<ButtonStyled color="brand">
|
||||||
)) ||
|
<button>
|
||||||
server.general?.project?.id === result.project_id
|
<DownloadIcon />
|
||||||
"
|
Download
|
||||||
disabled
|
</button>
|
||||||
class="btn btn-outline btn-primary"
|
</ButtonStyled>
|
||||||
>
|
<ButtonStyled circular>
|
||||||
<CheckIcon />
|
<button>
|
||||||
Installed
|
<HeartIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
</ButtonStyled>
|
||||||
v-else-if="(result as InstallableSearchResult).installing"
|
<ButtonStyled circular>
|
||||||
disabled
|
<button>
|
||||||
class="btn btn-outline btn-primary"
|
<BookmarkIcon />
|
||||||
>
|
</button>
|
||||||
Installing...
|
</ButtonStyled>
|
||||||
</button>
|
<ButtonStyled circular type="transparent">
|
||||||
<button
|
<button>
|
||||||
v-else
|
<MoreVerticalIcon />
|
||||||
class="btn btn-outline btn-primary"
|
</button>
|
||||||
@click="serverInstall(result as InstallableSearchResult)"
|
</ButtonStyled>
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
Install
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
</ProjectCard>
|
<template v-else-if="server">
|
||||||
<NuxtLink
|
<ButtonStyled color="brand" type="outlined">
|
||||||
v-if="flags.newProjectCards"
|
<button
|
||||||
:to="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
|
v-if="
|
||||||
>
|
(result as InstallableSearchResult).installed ||
|
||||||
<NewProjectCard :project="result" :categories="result.display_categories">
|
(server?.content?.data &&
|
||||||
<template v-if="false" #actions></template>
|
server.content.data.find(
|
||||||
</NewProjectCard>
|
(x: InstallableMod) => x.project_id === result.project_id,
|
||||||
</NuxtLink>
|
)) ||
|
||||||
</template>
|
server.general?.project?.id === result.project_id
|
||||||
</div>
|
"
|
||||||
|
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>
|
||||||
<div class="pagination-after">
|
<div class="pagination-after">
|
||||||
<pagination
|
<pagination
|
||||||
@@ -845,10 +916,6 @@ useSeoMeta({
|
|||||||
margin: 2rem;
|
margin: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#search-results {
|
|
||||||
min-height: 20vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 750px) {
|
@media screen and (min-width: 750px) {
|
||||||
.search-controls {
|
.search-controls {
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
|
|||||||
@@ -125,24 +125,25 @@
|
|||||||
<div class="results display-mode--list">
|
<div class="results display-mode--list">
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
v-for="result in searchProjects"
|
v-for="result in searchProjects"
|
||||||
:id="result.slug ? result.slug : result.project_id"
|
|
||||||
:key="result.project_id"
|
:key="result.project_id"
|
||||||
class="small-mode gradient-border"
|
class="gradient-border"
|
||||||
:type="result.project_type"
|
:link="`/${result.project_type}/${result.slug ? result.slug : result.project_id}`"
|
||||||
:author="result.author"
|
:title="result.title"
|
||||||
:name="result.title"
|
:author="{ name: result.author, link: `/user/${result.author}` }"
|
||||||
:description="result.description"
|
:summary="result.description"
|
||||||
:created-at="result.date_created"
|
:date-updated="result.date_modified"
|
||||||
:updated-at="result.date_modified"
|
:date-published="result.date_created"
|
||||||
:downloads="result.downloads.toString()"
|
:displayed-date="sortType === 'newest' ? 'published' : 'updated'"
|
||||||
:follows="result.follows.toString()"
|
:downloads="result.downloads"
|
||||||
|
:followers="result.follows"
|
||||||
:icon-url="result.icon_url"
|
:icon-url="result.icon_url"
|
||||||
:client-side="result.client_side"
|
:environment="{
|
||||||
:server-side="result.server_side"
|
clientSide: result.client_side,
|
||||||
:categories="result.display_categories.slice(0, 3)"
|
serverSide: result.server_side,
|
||||||
:search="true"
|
}"
|
||||||
:show-updated-date="true"
|
:tags="result.display_categories.slice(0, 3)"
|
||||||
:color="result.color"
|
:color="result.color"
|
||||||
|
layout="list"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -445,6 +446,7 @@ import {
|
|||||||
commonMessages,
|
commonMessages,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
IntlFormatted,
|
IntlFormatted,
|
||||||
|
ProjectCard,
|
||||||
useRelativeTime,
|
useRelativeTime,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
@@ -454,7 +456,6 @@ import { Multiselect } from 'vue-multiselect'
|
|||||||
import ATLauncherLogo from '~/assets/images/external/atlauncher.svg?component'
|
import ATLauncherLogo from '~/assets/images/external/atlauncher.svg?component'
|
||||||
import PrismLauncherLogo from '~/assets/images/external/prism.svg?component'
|
import PrismLauncherLogo from '~/assets/images/external/prism.svg?component'
|
||||||
import LatestNewsRow from '~/components/ui/news/LatestNewsRow.vue'
|
import LatestNewsRow from '~/components/ui/news/LatestNewsRow.vue'
|
||||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
|
||||||
import { homePageNotifs, homePageProjects, homePageSearch } from '~/generated/state.json'
|
import { homePageNotifs, homePageProjects, homePageSearch } from '~/generated/state.json'
|
||||||
|
|
||||||
const formatRelativeTime = useRelativeTime()
|
const formatRelativeTime = useRelativeTime()
|
||||||
|
|||||||
@@ -204,29 +204,28 @@
|
|||||||
)
|
)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => b.downloads - a.downloads)"
|
.sort((a, b) => b.downloads - a.downloads)"
|
||||||
:id="project.slug || project.id"
|
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
:name="project.name"
|
:link="`/${project.project_types[0] ?? 'project'}/${project.slug || project.id}`"
|
||||||
:display="cosmetics.searchDisplayMode.user"
|
:title="project.name"
|
||||||
: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()"
|
|
||||||
:icon-url="project.icon_url"
|
:icon-url="project.icon_url"
|
||||||
:categories="project.categories"
|
:banner="project.gallery.find((element) => element.featured)?.url"
|
||||||
:client-side="project.client_side"
|
:summary="project.summary"
|
||||||
:server-side="project.server_side"
|
:date-updated="project.updated"
|
||||||
|
:downloads="project.downloads"
|
||||||
|
:followers="project.followers"
|
||||||
|
:tags="project.categories"
|
||||||
|
:environment="{
|
||||||
|
clientSide: project.client_side,
|
||||||
|
serverSide: project.server_side,
|
||||||
|
}"
|
||||||
:status="
|
:status="
|
||||||
auth.user &&
|
auth.user &&
|
||||||
(auth.user.id! === (user as any).id || tags.staffRoles.includes(auth.user.role))
|
(auth.user.id! === (user as any).id || tags.staffRoles.includes(auth.user.role))
|
||||||
? (project.status as ProjectStatus)
|
? (project.status as ProjectStatus)
|
||||||
: undefined
|
: undefined
|
||||||
"
|
"
|
||||||
:type="project.project_types[0] ?? 'project'"
|
|
||||||
:color="project.color"
|
:color="project.color"
|
||||||
|
layout="list"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -267,6 +266,7 @@ import {
|
|||||||
commonMessages,
|
commonMessages,
|
||||||
ContentPageHeader,
|
ContentPageHeader,
|
||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
|
ProjectCard,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import type { Organization, ProjectStatus, ProjectType, ProjectV3 } from '@modrinth/utils'
|
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 ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
|
||||||
import NavStack from '~/components/ui/NavStack.vue'
|
import NavStack from '~/components/ui/NavStack.vue'
|
||||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
|
||||||
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
|
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
|
||||||
import {
|
import {
|
||||||
OrganizationContext,
|
OrganizationContext,
|
||||||
|
|||||||
@@ -59,41 +59,17 @@
|
|||||||
class="radio shrink-0"
|
class="radio shrink-0"
|
||||||
/>
|
/>
|
||||||
<RadioButtonIcon v-else class="radio shrink-0" />
|
<RadioButtonIcon v-else class="radio shrink-0" />
|
||||||
Rows
|
List
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="preview-radio button-base"
|
class="preview-radio button-base"
|
||||||
:class="{
|
:class="{
|
||||||
selected: cosmetics.searchDisplayMode[projectType.id] === 'grid',
|
selected:
|
||||||
|
cosmetics.searchDisplayMode[projectType.id] === 'gallery' ||
|
||||||
|
cosmetics.searchDisplayMode[projectType.id] === 'grid',
|
||||||
}"
|
}"
|
||||||
@click="() => (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="preview">
|
||||||
<div class="layout-gallery-mode">
|
<div class="layout-gallery-mode">
|
||||||
@@ -105,11 +81,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="label">
|
<div class="label">
|
||||||
<RadioButtonCheckedIcon
|
<RadioButtonCheckedIcon
|
||||||
v-if="cosmetics.searchDisplayMode[projectType.id] === 'gallery'"
|
v-if="
|
||||||
|
cosmetics.searchDisplayMode[projectType.id] === 'gallery' ||
|
||||||
|
cosmetics.searchDisplayMode[projectType.id] === 'grid'
|
||||||
|
"
|
||||||
class="radio shrink-0"
|
class="radio shrink-0"
|
||||||
/>
|
/>
|
||||||
<RadioButtonIcon v-else class="radio shrink-0" />
|
<RadioButtonIcon v-else class="radio shrink-0" />
|
||||||
Gallery
|
Grid
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -301,27 +301,32 @@
|
|||||||
)
|
)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => b.downloads - a.downloads)"
|
.sort((a, b) => b.downloads - a.downloads)"
|
||||||
:id="project.slug || project.id"
|
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
:name="project.title"
|
:link="`/${project.project_type ?? 'project'}/${project.slug ? project.slug : project.id}`"
|
||||||
:display="cosmetics.searchDisplayMode.user"
|
:title="project.title"
|
||||||
: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()"
|
|
||||||
:icon-url="project.icon_url"
|
:icon-url="project.icon_url"
|
||||||
:categories="project.categories"
|
:date-updated="project.updated"
|
||||||
:client-side="project.client_side"
|
:downloads="project.downloads"
|
||||||
:server-side="project.server_side"
|
:summary="project.description"
|
||||||
:status="
|
:tags="[...project.categories]"
|
||||||
auth.user && (auth.user.id === user.id || tags.staffRoles.includes(auth.user.role))
|
:all-tags="[
|
||||||
? project.status
|
...project.categories,
|
||||||
: null
|
...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"
|
:status="project.status"
|
||||||
:color="project.color"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -493,6 +498,7 @@ import {
|
|||||||
IntlFormatted,
|
IntlFormatted,
|
||||||
NewModal,
|
NewModal,
|
||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
|
ProjectCard,
|
||||||
TagItem,
|
TagItem,
|
||||||
useRelativeTime,
|
useRelativeTime,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
@@ -511,7 +517,6 @@ import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
|||||||
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
|
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
|
||||||
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
|
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
|
||||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
|
||||||
import { reportUser } from '~/utils/report-helpers.ts'
|
import { reportUser } from '~/utils/report-helpers.ts'
|
||||||
|
|
||||||
const data = useNuxtApp()
|
const data = useNuxtApp()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
button:focus-visible,
|
button:focus-visible:not(.custom-focus-indicator),
|
||||||
a:focus-visible,
|
a:focus-visible:not(.custom-focus-indicator),
|
||||||
[tabindex='0']:focus-visible {
|
[tabindex='0']:focus-visible:not(.custom-focus-indicator),
|
||||||
outline: 0.25rem solid #ea80ff;
|
[type='button']:focus-visible:not(.custom-focus-indicator) {
|
||||||
border-radius: 0.25rem;
|
outline: 0.25rem solid var(--color-focus-ring);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,7 +217,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CLICKABLES/BUTTONS
|
// CLICKABLES/BUTTONS
|
||||||
a,
|
a:not(.no-click-animation),
|
||||||
.clickable {
|
.clickable {
|
||||||
transition:
|
transition:
|
||||||
opacity 0.5s ease-in-out,
|
opacity 0.5s ease-in-out,
|
||||||
@@ -522,58 +522,6 @@ a,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PROJECT CARDS
|
|
||||||
|
|
||||||
.project-list {
|
|
||||||
width: 100%;
|
|
||||||
gap: var(--gap-md);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&:not(:first-child) {
|
|
||||||
margin-top: var(--gap-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:empty) {
|
|
||||||
margin-bottom: var(--gap-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-list.display-mode--list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-list.display-mode--gallery {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
|
|
||||||
@media screen and (max-width: 750px) {
|
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-list.display-mode--grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
|
|
||||||
@media screen and (max-width: 80rem) {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 1024px) {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 860px) {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 550px) {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CARDS
|
// CARDS
|
||||||
.base-card {
|
.base-card {
|
||||||
padding: var(--gap-xl);
|
padding: var(--gap-xl);
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ html {
|
|||||||
--color-link-active: var(--color-blue) !important; // DEPRECATED, use filters in future
|
--color-link-active: var(--color-blue) !important; // DEPRECATED, use filters in future
|
||||||
|
|
||||||
--shadow-button: 0 1px 3px 0 rgba(0, 0, 0, 0.05), 0 1px 2px 0 rgba(0, 0, 0, 0.15);
|
--shadow-button: 0 1px 3px 0 rgba(0, 0, 0, 0.05), 0 1px 2px 0 rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
--color-focus-ring: #ea80ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.light-mode,
|
.light-mode,
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
<template>
|
<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 />
|
<slot />
|
||||||
</router-link>
|
</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 />
|
<slot />
|
||||||
</a>
|
</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">
|
<span v-else v-bind="$attrs">
|
||||||
<slot />
|
<slot />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -138,14 +138,17 @@ function hash(str) {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.avatar {
|
.avatar {
|
||||||
@apply min-w-[--_size] min-h-[--_size] w-[--_size] h-[--_size];
|
|
||||||
--_size: 2rem;
|
--_size: 2rem;
|
||||||
|
|
||||||
border: 1px solid var(--color-button-border);
|
border: 1px solid var(--surface-5);
|
||||||
background-color: var(--color-button-bg);
|
background-color: var(--color-button-bg);
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: calc(16 / 96 * var(--_size));
|
border-radius: calc(16 / 96 * var(--_override-size, var(--_size)));
|
||||||
position: relative;
|
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 {
|
&.circle {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ function toggleItem(item: T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 0.25rem solid #ea80ff;
|
outline: 0.25rem solid var(--color-focus-ring);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ label {
|
|||||||
flex-direction: unset;
|
flex-direction: unset;
|
||||||
max-height: unset;
|
max-height: unset;
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
outline: 0.25rem solid #ea80ff;
|
outline: 0.25rem solid var(--color-focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
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
|
// When clickable is being hovered or focus-visible, give contents an effect
|
||||||
&:has(> *:first-child:hover, > *:first-child:focus-visible) .smart-clickable__contents {
|
:first-child:hover + .smart-clickable__contents,
|
||||||
filter: var(--hover-filter-weak);
|
:first-child:focus-visible + .smart-clickable__contents {
|
||||||
|
|
||||||
// Utility classes for contents
|
// Utility classes for contents
|
||||||
:deep(.smart-clickable\:underline-on-hover) {
|
:deep(.smart-clickable\:underline-on-hover) {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility classes for contents
|
|
||||||
:deep(.smart-clickable\:highlight-on-hover) {
|
:deep(.smart-clickable\:highlight-on-hover) {
|
||||||
filter: brightness(var(--hover-brightness, 1.25));
|
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
|
// 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;
|
scale: 0.97;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
v-if="action"
|
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"
|
@click="action"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div v-else :class="baseClass">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -17,4 +14,7 @@
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
action?: (event: MouseEvent) => void
|
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>
|
</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 PreviewSelectButton } from './PreviewSelectButton.vue'
|
||||||
export { default as ProgressBar } from './ProgressBar.vue'
|
export { default as ProgressBar } from './ProgressBar.vue'
|
||||||
export { default as ProgressSpinner } from './ProgressSpinner.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 RadialHeader } from './RadialHeader.vue'
|
||||||
export { default as RadioButtons } from './RadioButtons.vue'
|
export { default as RadioButtons } from './RadioButtons.vue'
|
||||||
export { default as ScrollablePanel } from './ScrollablePanel.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 type { TableColumn } from './Table.vue'
|
||||||
export { default as Table } from './Table.vue'
|
export { default as Table } from './Table.vue'
|
||||||
export { default as TagItem } from './TagItem.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 Timeline } from './Timeline.vue'
|
||||||
export { default as Toggle } from './Toggle.vue'
|
export { default as Toggle } from './Toggle.vue'
|
||||||
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.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>
|
<template #stats>
|
||||||
<div
|
<div
|
||||||
v-tooltip="
|
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"
|
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>
|
||||||
<div
|
<div
|
||||||
v-tooltip="
|
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="flex items-center gap-2 border-0 border-solid border-divider pr-4 cursor-help"
|
||||||
:class="{ 'md:border-r': project.categories.length > 0 }"
|
:class="{ 'md:border-r': project.categories.length > 0 }"
|
||||||
@@ -54,9 +62,11 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DownloadIcon, HeartIcon, TagsIcon } from '@modrinth/assets'
|
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 { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { useVIntl } from '../../composables'
|
||||||
|
import { commonMessages } from '../../utils'
|
||||||
import Avatar from '../base/Avatar.vue'
|
import Avatar from '../base/Avatar.vue'
|
||||||
import ContentPageHeader from '../base/ContentPageHeader.vue'
|
import ContentPageHeader from '../base/ContentPageHeader.vue'
|
||||||
import FormattedTag from '../base/FormattedTag.vue'
|
import FormattedTag from '../base/FormattedTag.vue'
|
||||||
@@ -64,6 +74,7 @@ import TagItem from '../base/TagItem.vue'
|
|||||||
import ProjectStatusBadge from './ProjectStatusBadge.vue'
|
import ProjectStatusBadge from './ProjectStatusBadge.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
withDefaults(
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|||||||
@@ -5,26 +5,29 @@
|
|||||||
<div>
|
<div>
|
||||||
<BookTextIcon aria-hidden="true" />
|
<BookTextIcon aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
Licensed
|
<IntlFormatted :message-id="messages.licensed">
|
||||||
<a
|
<template #~license>
|
||||||
v-if="project.license.url"
|
<a
|
||||||
class="text-link hover:underline"
|
v-if="project.license.url"
|
||||||
:href="project.license.url"
|
class="text-link hover:underline"
|
||||||
:target="linkTarget"
|
:href="project.license.url"
|
||||||
rel="noopener nofollow ugc"
|
:target="linkTarget"
|
||||||
>
|
rel="noopener nofollow ugc"
|
||||||
{{ licenseIdDisplay }}
|
>
|
||||||
<ExternalIcon aria-hidden="true" class="external-icon ml-1 mt-[-1px] inline" />
|
{{ licenseIdDisplay }}
|
||||||
</a>
|
<ExternalIcon aria-hidden="true" class="external-icon ml-1 mt-[-1px] inline" />
|
||||||
<span
|
</a>
|
||||||
v-else-if="
|
<span
|
||||||
project.license.id === 'LicenseRef-All-Rights-Reserved' ||
|
v-else-if="
|
||||||
!project.license.id.includes('LicenseRef')
|
project.license.id === 'LicenseRef-All-Rights-Reserved' ||
|
||||||
"
|
!project.license.id.includes('LicenseRef')
|
||||||
>
|
"
|
||||||
{{ licenseIdDisplay }}
|
>
|
||||||
</span>
|
{{ licenseIdDisplay }}
|
||||||
<span v-else>{{ licenseIdDisplay }}</span>
|
</span>
|
||||||
|
<span v-else>{{ licenseIdDisplay }}</span>
|
||||||
|
</template>
|
||||||
|
</IntlFormatted>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -33,13 +36,19 @@
|
|||||||
>
|
>
|
||||||
<CalendarIcon aria-hidden="true" />
|
<CalendarIcon aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
{{ capitalizeString(formatMessage(messages.published, { date: publishedDate })) }}
|
{{
|
||||||
|
capitalizeString(
|
||||||
|
formatMessage(commonMessages.projectPublished, { date: publishedDate }),
|
||||||
|
)
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else v-tooltip="dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')">
|
<div v-else v-tooltip="dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')">
|
||||||
<CalendarIcon aria-hidden="true" />
|
<CalendarIcon aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
{{ capitalizeString(formatMessage(messages.created, { date: createdDate })) }}
|
{{
|
||||||
|
capitalizeString(formatMessage(commonMessages.projectCreated, { date: createdDate }))
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -48,7 +57,11 @@
|
|||||||
>
|
>
|
||||||
<ScaleIcon aria-hidden="true" />
|
<ScaleIcon aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
{{ capitalizeString(formatMessage(messages.submitted, { date: submittedDate })) }}
|
{{
|
||||||
|
capitalizeString(
|
||||||
|
formatMessage(commonMessages.projectSubmitted, { date: submittedDate }),
|
||||||
|
)
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -57,7 +70,9 @@
|
|||||||
>
|
>
|
||||||
<VersionIcon aria-hidden="true" />
|
<VersionIcon aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
{{ capitalizeString(formatMessage(messages.updated, { date: updatedDate })) }}
|
{{
|
||||||
|
capitalizeString(formatMessage(commonMessages.projectUpdated, { date: updatedDate }))
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,6 +87,7 @@ import { computed } from 'vue'
|
|||||||
import { useRelativeTime } from '../../composables'
|
import { useRelativeTime } from '../../composables'
|
||||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||||
import { commonMessages } from '../../utils/common-messages'
|
import { commonMessages } from '../../utils/common-messages'
|
||||||
|
import { IntlFormatted } from '../base'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const formatRelativeTime = useRelativeTime()
|
const formatRelativeTime = useRelativeTime()
|
||||||
@@ -123,21 +139,5 @@ const messages = defineMessages({
|
|||||||
id: 'project.about.details.licensed',
|
id: 'project.about.details.licensed',
|
||||||
defaultMessage: 'Licensed {license}',
|
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>
|
</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'
|
export * from './settings'
|
||||||
|
|
||||||
// Other
|
// 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 ProjectBackgroundGradient } from './ProjectBackgroundGradient.vue'
|
||||||
|
export { default as ProjectCardList } from './ProjectCardList.vue'
|
||||||
export { default as ProjectHeader } from './ProjectHeader.vue'
|
export { default as ProjectHeader } from './ProjectHeader.vue'
|
||||||
export { default as ProjectPageDescription } from './ProjectPageDescription.vue'
|
export { default as ProjectPageDescription } from './ProjectPageDescription.vue'
|
||||||
export { default as ProjectPageVersions } from './ProjectPageVersions.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 ProjectSidebarDetails } from './ProjectSidebarDetails.vue'
|
||||||
export { default as ProjectSidebarLinks } from './ProjectSidebarLinks.vue'
|
export { default as ProjectSidebarLinks } from './ProjectSidebarLinks.vue'
|
||||||
export { default as ProjectStatusBadge } from './ProjectStatusBadge.vue'
|
export { default as ProjectStatusBadge } from './ProjectStatusBadge.vue'
|
||||||
|
export { default as TagsOverflow } from './TagsOverflow.vue'
|
||||||
|
|||||||
@@ -650,6 +650,18 @@
|
|||||||
"payment-method.visa": {
|
"payment-method.visa": {
|
||||||
"defaultMessage": "Visa"
|
"defaultMessage": "Visa"
|
||||||
},
|
},
|
||||||
|
"project-card.environment.client": {
|
||||||
|
"defaultMessage": "Client"
|
||||||
|
},
|
||||||
|
"project-card.environment.client-and-server": {
|
||||||
|
"defaultMessage": "Client and server"
|
||||||
|
},
|
||||||
|
"project-card.environment.client-or-server": {
|
||||||
|
"defaultMessage": "Client or server"
|
||||||
|
},
|
||||||
|
"project-card.environment.server": {
|
||||||
|
"defaultMessage": "Server"
|
||||||
|
},
|
||||||
"project-type.all": {
|
"project-type.all": {
|
||||||
"defaultMessage": "All"
|
"defaultMessage": "All"
|
||||||
},
|
},
|
||||||
@@ -809,6 +821,9 @@
|
|||||||
"project.about.links.wiki": {
|
"project.about.links.wiki": {
|
||||||
"defaultMessage": "Visit wiki"
|
"defaultMessage": "Visit wiki"
|
||||||
},
|
},
|
||||||
|
"project.download-count-tooltip": {
|
||||||
|
"defaultMessage": "{count} {count, plural, one {download} other {downloads}}"
|
||||||
|
},
|
||||||
"project.environment.client-and-server.description": {
|
"project.environment.client-and-server.description": {
|
||||||
"defaultMessage": "Has some functionality on both the client and server, even if only partially."
|
"defaultMessage": "Has some functionality on both the client and server, even if only partially."
|
||||||
},
|
},
|
||||||
@@ -875,6 +890,9 @@
|
|||||||
"project.environment.unknown.title": {
|
"project.environment.unknown.title": {
|
||||||
"defaultMessage": "Unknown environment"
|
"defaultMessage": "Unknown environment"
|
||||||
},
|
},
|
||||||
|
"project.follower-count-tooltip": {
|
||||||
|
"defaultMessage": "{count} {count, plural, one {followers} other {followers}}"
|
||||||
|
},
|
||||||
"project.settings.analytics.title": {
|
"project.settings.analytics.title": {
|
||||||
"defaultMessage": "Analytics"
|
"defaultMessage": "Analytics"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
import ProjectCard from '../../components/base/ProjectCard.vue'
|
import ProjectCard from '../../components/project/card/ProjectCard.vue'
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Base/ProjectCard',
|
title: 'Base/ProjectCard',
|
||||||
@@ -18,22 +18,21 @@ type Story = StoryObj<typeof meta>
|
|||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {
|
args: {
|
||||||
id: 'example-mod',
|
link: '/mod/example-mod',
|
||||||
type: 'mod',
|
layout: 'grid',
|
||||||
name: 'Example Mod',
|
title: 'Example Mod',
|
||||||
author: 'ModAuthor',
|
author: { name: 'Prospector', link: 'https://modrinth.com/user/Prospector' },
|
||||||
description:
|
summary:
|
||||||
'An example mod that demonstrates the ProjectCard component with a detailed description.',
|
'An example mod that demonstrates the ProjectCard component with a detailed description.',
|
||||||
iconUrl: 'https://cdn.modrinth.com/data/AANobbMI/icon.png',
|
iconUrl: 'https://cdn.modrinth.com/data/AANobbMI/icon.png',
|
||||||
downloads: '1234567',
|
downloads: 1234567,
|
||||||
follows: '12345',
|
followers: 12345,
|
||||||
createdAt: '2023-01-15T00:00:00Z',
|
dateUpdated: '2024-01-15T00:00:00Z',
|
||||||
updatedAt: '2024-01-15T00:00:00Z',
|
tags: ['adventure', 'decoration'],
|
||||||
categories: ['adventure', 'decoration'],
|
environment: {
|
||||||
projectTypeDisplay: 'Mod',
|
clientSide: 'required',
|
||||||
projectTypeUrl: 'mod',
|
serverSide: 'optional',
|
||||||
clientSide: 'required',
|
},
|
||||||
serverSide: 'optional',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,59 +42,50 @@ export const AllTypes: Story = {
|
|||||||
template: `
|
template: `
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
id="example-mod"
|
link="/mod/example-mod"
|
||||||
type="mod"
|
layout="grid"
|
||||||
name="Example Mod"
|
title="Example Mod"
|
||||||
author="ModAuthor"
|
:author="{ name: 'ModAuthor', link: '/user/ModAuthor' }"
|
||||||
description="A wonderful mod that adds new features to the game."
|
summary="A wonderful mod that adds new features to the game."
|
||||||
downloads="1000000"
|
:downloads="1000000"
|
||||||
follows="50000"
|
:followers="50000"
|
||||||
createdAt="2023-01-15T00:00:00Z"
|
date-updated="2023-01-15T00:00:00Z"
|
||||||
:categories="['technology', 'magic']"
|
:tags="['technology', 'magic']"
|
||||||
projectTypeDisplay="Mod"
|
:environment="{ clientSide: 'required', serverSide: 'optional' }"
|
||||||
projectTypeUrl="mod"
|
|
||||||
clientSide="required"
|
|
||||||
serverSide="optional"
|
|
||||||
/>
|
/>
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
id="example-plugin"
|
link="/plugin/example-plugin"
|
||||||
type="plugin"
|
layout="grid"
|
||||||
name="Example Plugin"
|
title="Example Plugin"
|
||||||
author="PluginDev"
|
:author="{ name: 'PluginDev', link: '/user/PluginDev' }"
|
||||||
description="A server plugin for managing permissions."
|
summary="A server plugin for managing permissions."
|
||||||
downloads="500000"
|
:downloads="500000"
|
||||||
follows="25000"
|
:followers="25000"
|
||||||
createdAt="2023-06-01T00:00:00Z"
|
date-updated="2023-06-01T00:00:00Z"
|
||||||
:categories="['utility']"
|
:tags="['utility']"
|
||||||
projectTypeDisplay="Plugin"
|
:environment="{ clientSide: 'unsupported', serverSide: 'required' }"
|
||||||
projectTypeUrl="plugin"
|
|
||||||
serverSide="required"
|
|
||||||
/>
|
/>
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
id="example-modpack"
|
link="/modpack/example-modpack"
|
||||||
type="modpack"
|
layout="grid"
|
||||||
name="Example Modpack"
|
title="Example Modpack"
|
||||||
author="PackCreator"
|
:author="{ name: 'PackCreator', link: '/user/PackCreator' }"
|
||||||
description="A curated collection of mods for the best experience."
|
summary="A curated collection of mods for the best experience."
|
||||||
downloads="250000"
|
:downloads="250000"
|
||||||
follows="10000"
|
:followers="10000"
|
||||||
createdAt="2023-03-20T00:00:00Z"
|
date-updated="2023-03-20T00:00:00Z"
|
||||||
:categories="['adventure']"
|
:tags="['adventure']"
|
||||||
projectTypeDisplay="Modpack"
|
|
||||||
projectTypeUrl="modpack"
|
|
||||||
/>
|
/>
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
id="example-resourcepack"
|
link="/resourcepack/example-resourcepack"
|
||||||
type="resourcepack"
|
layout="grid"
|
||||||
name="HD Textures"
|
title="HD Textures"
|
||||||
author="ArtistName"
|
:author="{ name: 'ArtistName', link: '/user/ArtistName' }"
|
||||||
description="High definition textures for a better visual experience."
|
summary="High definition textures for a better visual experience."
|
||||||
downloads="750000"
|
:downloads="750000"
|
||||||
follows="30000"
|
:followers="30000"
|
||||||
createdAt="2022-12-01T00:00:00Z"
|
date-updated="2022-12-01T00:00:00Z"
|
||||||
:categories="['realistic']"
|
:tags="['realistic']"
|
||||||
projectTypeDisplay="Resource Pack"
|
|
||||||
projectTypeUrl="resourcepack"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
@@ -108,31 +98,27 @@ export const WithStatus: Story = {
|
|||||||
template: `
|
template: `
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
id="draft-mod"
|
link="/mod/draft-mod"
|
||||||
type="mod"
|
layout="grid"
|
||||||
name="Draft Project"
|
title="Draft Project"
|
||||||
author="Developer"
|
:author="{ name: 'Developer', link: '/user/Developer' }"
|
||||||
description="This project is still in draft mode."
|
summary="This project is still in draft mode."
|
||||||
downloads="0"
|
:downloads="0"
|
||||||
follows="0"
|
:followers="0"
|
||||||
createdAt="2024-01-01T00:00:00Z"
|
date-updated="2024-01-01T00:00:00Z"
|
||||||
:categories="['utility']"
|
:tags="['utility']"
|
||||||
projectTypeDisplay="Mod"
|
|
||||||
projectTypeUrl="mod"
|
|
||||||
status="draft"
|
status="draft"
|
||||||
/>
|
/>
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
id="pending-mod"
|
link="/mod/pending-mod"
|
||||||
type="mod"
|
layout="grid"
|
||||||
name="Pending Review"
|
title="Pending Review"
|
||||||
author="Developer"
|
:author="{ name: 'Developer', link: '/user/Developer' }"
|
||||||
description="This project is pending review."
|
summary="This project is pending review."
|
||||||
downloads="0"
|
:downloads="0"
|
||||||
follows="0"
|
:followers="0"
|
||||||
createdAt="2024-01-01T00:00:00Z"
|
date-updated="2024-01-01T00:00:00Z"
|
||||||
:categories="['utility']"
|
:tags="['utility']"
|
||||||
projectTypeDisplay="Mod"
|
|
||||||
projectTypeUrl="mod"
|
|
||||||
status="processing"
|
status="processing"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,19 +136,16 @@ export const DisplayModes: StoryObj = {
|
|||||||
<h3 class="text-lg font-bold mb-4">Grid Mode</h3>
|
<h3 class="text-lg font-bold mb-4">Grid Mode</h3>
|
||||||
<div class="display-mode--grid">
|
<div class="display-mode--grid">
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
id="grid-mod"
|
link="/mod/grid-mod"
|
||||||
type="mod"
|
layout="grid"
|
||||||
name="Example Mod"
|
title="Example Mod"
|
||||||
author="ModAuthor"
|
:author="{ name: 'ModAuthor', link: '/user/ModAuthor' }"
|
||||||
description="A wonderful mod that adds new features to the game."
|
summary="A wonderful mod that adds new features to the game."
|
||||||
downloads="1000000"
|
:downloads="1000000"
|
||||||
follows="50000"
|
:followers="50000"
|
||||||
createdAt="2023-01-15T00:00:00Z"
|
date-updated="2023-01-15T00:00:00Z"
|
||||||
:categories="['technology', 'magic']"
|
:tags="['technology', 'magic']"
|
||||||
projectTypeDisplay="Mod"
|
:environment="{ clientSide: 'required', serverSide: 'optional' }"
|
||||||
projectTypeUrl="mod"
|
|
||||||
clientSide="required"
|
|
||||||
serverSide="optional"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,40 +153,34 @@ export const DisplayModes: StoryObj = {
|
|||||||
<h3 class="text-lg font-bold mb-4">List Mode</h3>
|
<h3 class="text-lg font-bold mb-4">List Mode</h3>
|
||||||
<div class="display-mode--list">
|
<div class="display-mode--list">
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
id="list-mod"
|
link="/mod/list-mod"
|
||||||
type="mod"
|
layout="list"
|
||||||
name="Example Mod"
|
title="Example Mod"
|
||||||
author="ModAuthor"
|
:author="{ name: 'ModAuthor', link: '/user/ModAuthor' }"
|
||||||
description="A wonderful mod that adds new features to the game."
|
summary="A wonderful mod that adds new features to the game."
|
||||||
downloads="1000000"
|
:downloads="1000000"
|
||||||
follows="50000"
|
:followers="50000"
|
||||||
createdAt="2023-01-15T00:00:00Z"
|
date-updated="2023-01-15T00:00:00Z"
|
||||||
:categories="['technology', 'magic']"
|
:tags="['technology', 'magic']"
|
||||||
projectTypeDisplay="Mod"
|
:environment="{ clientSide: 'required', serverSide: 'optional' }"
|
||||||
projectTypeUrl="mod"
|
|
||||||
clientSide="required"
|
|
||||||
serverSide="optional"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-bold mb-4">Gallery Mode</h3>
|
<h3 class="text-lg font-bold mb-4">Grid with Banner</h3>
|
||||||
<div class="display-mode--gallery">
|
<div class="display-mode--grid">
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
id="gallery-mod"
|
link="/mod/gallery-mod"
|
||||||
type="mod"
|
layout="grid"
|
||||||
name="Example Mod"
|
title="Example Mod"
|
||||||
author="ModAuthor"
|
:author="{ name: 'ModAuthor', link: '/user/ModAuthor' }"
|
||||||
description="A wonderful mod that adds new features to the game."
|
summary="A wonderful mod that adds new features to the game."
|
||||||
downloads="1000000"
|
:downloads="1000000"
|
||||||
follows="50000"
|
:followers="50000"
|
||||||
createdAt="2023-01-15T00:00:00Z"
|
date-updated="2023-01-15T00:00:00Z"
|
||||||
:categories="['technology', 'magic']"
|
:tags="['technology', 'magic']"
|
||||||
projectTypeDisplay="Mod"
|
:environment="{ clientSide: 'required', serverSide: 'optional' }"
|
||||||
projectTypeUrl="mod"
|
banner="https://cdn.modrinth.com/data/AANobbMI/images/be1cc1abc9cd9c2f52bb6a39be0b4b05af24d813.png"
|
||||||
clientSide="required"
|
|
||||||
serverSide="optional"
|
|
||||||
featuredImage="https://cdn.modrinth.com/data/AANobbMI/images/be1cc1abc9cd9c2f52bb6a39be0b4b05af24d813.png"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -369,6 +369,30 @@ export const commonMessages = defineMessages({
|
|||||||
id: 'label.no-items',
|
id: 'label.no-items',
|
||||||
defaultMessage: 'No items',
|
defaultMessage: 'No items',
|
||||||
},
|
},
|
||||||
|
projectCreated: {
|
||||||
|
id: 'project.about.details.created',
|
||||||
|
defaultMessage: 'Created {date}',
|
||||||
|
},
|
||||||
|
projectSubmitted: {
|
||||||
|
id: 'project.about.details.submitted',
|
||||||
|
defaultMessage: 'Submitted {date}',
|
||||||
|
},
|
||||||
|
projectPublished: {
|
||||||
|
id: 'project.about.details.published',
|
||||||
|
defaultMessage: 'Published {date}',
|
||||||
|
},
|
||||||
|
projectUpdated: {
|
||||||
|
id: 'project.about.details.updated',
|
||||||
|
defaultMessage: 'Updated {date}',
|
||||||
|
},
|
||||||
|
projectDownloads: {
|
||||||
|
id: 'project.download-count-tooltip',
|
||||||
|
defaultMessage: '{count} {count, plural, one {download} other {downloads}}',
|
||||||
|
},
|
||||||
|
projectFollowers: {
|
||||||
|
id: 'project.follower-count-tooltip',
|
||||||
|
defaultMessage: '{count} {count, plural, one {followers} other {followers}}',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const formFieldLabels = defineMessages({
|
export const formFieldLabels = defineMessages({
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import { type Component, computed, readonly, type Ref, ref } from 'vue'
|
|||||||
import { type LocationQueryRaw, type LocationQueryValue, useRoute } from 'vue-router'
|
import { type LocationQueryRaw, type LocationQueryValue, useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { defineMessage, useVIntl } from '../composables/i18n'
|
import { defineMessage, useVIntl } from '../composables/i18n'
|
||||||
import { formatCategory, formatCategoryHeader, formatLoader } from './tag-messages.ts'
|
import {
|
||||||
|
DEFAULT_MOD_LOADERS,
|
||||||
|
DEFAULT_SHADER_LOADERS,
|
||||||
|
formatCategory,
|
||||||
|
formatCategoryHeader,
|
||||||
|
formatLoader,
|
||||||
|
} from './tag-messages.ts'
|
||||||
|
|
||||||
type BaseOption = {
|
type BaseOption = {
|
||||||
id: string
|
id: string
|
||||||
@@ -245,7 +251,7 @@ export function useSearch(
|
|||||||
display: 'expandable',
|
display: 'expandable',
|
||||||
query_param: 'g',
|
query_param: 'g',
|
||||||
supports_negative_filter: true,
|
supports_negative_filter: true,
|
||||||
default_values: ['fabric', 'forge', 'neoforge'],
|
default_values: DEFAULT_MOD_LOADERS,
|
||||||
searchable: false,
|
searchable: false,
|
||||||
options: tags.value.loaders
|
options: tags.value.loaders
|
||||||
.filter(
|
.filter(
|
||||||
@@ -357,7 +363,7 @@ export function useSearch(
|
|||||||
supports_negative_filter: true,
|
supports_negative_filter: true,
|
||||||
searchable: false,
|
searchable: false,
|
||||||
display: 'expandable',
|
display: 'expandable',
|
||||||
default_values: ['iris', 'optifine', 'vanilla'],
|
default_values: DEFAULT_SHADER_LOADERS,
|
||||||
options: tags.value.loaders
|
options: tags.value.loaders
|
||||||
.filter((loader) => loader.supported_project_types.includes('shader'))
|
.filter((loader) => loader.supported_project_types.includes('shader'))
|
||||||
.map((loader) => {
|
.map((loader) => {
|
||||||
|
|||||||
@@ -388,6 +388,29 @@ export const categoryMessages = defineMessages({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const DEFAULT_MOD_LOADERS: string[] = ['fabric', 'forge', 'neoforge']
|
||||||
|
export const DEFAULT_SHADER_LOADERS: string[] = ['iris', 'optifine', 'vanilla']
|
||||||
|
|
||||||
|
const DEFAULT_LOADER_NAMES = new Set([...DEFAULT_MOD_LOADERS, ...DEFAULT_SHADER_LOADERS])
|
||||||
|
|
||||||
|
// sort by:
|
||||||
|
// 1. categories, alphabetically
|
||||||
|
// 2. default loaders, alphabetically
|
||||||
|
// 3. other loaders, alphabetically
|
||||||
|
export function sortTagsForDisplay(tags: string[]): string[] {
|
||||||
|
const isLoader = (tag: string) => getTagMessage(tag, 'loader') !== undefined
|
||||||
|
const loaders = tags.filter(isLoader)
|
||||||
|
const categories = tags.filter((tag) => !isLoader(tag))
|
||||||
|
categories.sort((a, b) => a.localeCompare(b))
|
||||||
|
loaders.sort((a, b) => {
|
||||||
|
const aDefault = DEFAULT_LOADER_NAMES.has(a)
|
||||||
|
const bDefault = DEFAULT_LOADER_NAMES.has(b)
|
||||||
|
if (aDefault !== bDefault) return aDefault ? -1 : 1
|
||||||
|
return a.localeCompare(b)
|
||||||
|
})
|
||||||
|
return [...categories, ...loaders]
|
||||||
|
}
|
||||||
|
|
||||||
export const categoryHeaderMessages = defineMessages({
|
export const categoryHeaderMessages = defineMessages({
|
||||||
resolutions: {
|
resolutions: {
|
||||||
id: 'header.category.resolutions',
|
id: 'header.category.resolutions',
|
||||||
|
|||||||
Reference in New Issue
Block a user