feat: new user badges, ui consistency pass (#6262)

* feat: new user badges, ui consistency pass

* prepr

* fix: align with backend

* fix: lint

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Prospector
2026-05-31 08:25:31 -07:00
committed by GitHub
parent cc8d556448
commit 34b87991bc
47 changed files with 947 additions and 142 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
v-if="filteredLinks.length > 1"
ref="scrollContainer"
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
:class="{ 'drop-shadow-xl': mode === 'navigation' }"
:class="{ 'drop-shadow-xl border border-solid border-surface-4': mode === 'navigation' }"
>
<template v-if="mode === 'navigation'">
<RouterLink
+1
View File
@@ -14,4 +14,5 @@ export * from './search'
export * from './servers'
export * from './settings'
export * from './skin'
export * from './user'
export * from './version'
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { ExternalIcon } from '@modrinth/assets'
import { Tooltip } from 'floating-vue'
import { type Component, useId } from 'vue'
import { type MessageDescriptor, useVIntl } from '#ui/composables/i18n.ts'
import AutoLink from '../base/AutoLink.vue'
const { formatMessage } = useVIntl()
defineProps<{
icon: Component
name: MessageDescriptor
about: MessageDescriptor[]
values?: Record<string, unknown>
link?: {
href: string
message: MessageDescriptor
}
}>()
const baseId = useId()
</script>
<template>
<Tooltip theme="tooltip" :triggers="['hover', 'focus']" :aria-id="`${baseId}-${name.id}`">
<AutoLink
:to="link?.href"
class="rounded-2xl flex"
:class="{
'hover:bg-surface-4 focus:bg-surface-4': !!link,
}"
target="_blank"
tabindex="0"
>
<component :is="icon" class="size-full p-0.5" />
</AutoLink>
<template #popper>
<div class="flex flex-col max-w-[22rem] leading-tight gap-0.5">
<span class="text-contrast mb-1">{{ formatMessage(name, values) }}</span>
<span v-for="message of about" :key="message.id" class="text-primary">
{{ formatMessage(message, values) }}
</span>
<span v-if="link" class="text-secondary text-xs opacity-80">
{{ formatMessage(link.message, values) }} <ExternalIcon />
</span>
</div>
</template>
</Tooltip>
</template>
@@ -0,0 +1,507 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
AlphaBadge,
BetaBadge,
Downloads1mBadge,
Downloads10mBadge,
Downloads25mBadge,
Downloads50mBadge,
Downloads100mBadge,
Downloads250mBadge,
Downloads500mBadge,
EarlyDatapackBadge,
EarlyHostingBadge,
EarlyModpackBadge,
EarlyPluginBadge,
EarlyResourcepackBadge,
EarlyServersBadge,
EarlyShadersBadge,
ModeratorBadge,
PlusBadge,
PrideBadge,
StaffBadge,
} from '@modrinth/assets'
import {
defineMessage,
defineMessages,
type MessageDescriptor,
useFormatNumber,
useVIntl,
} from '@modrinth/ui'
import { UserBadge as BadgeBitflag } from '@modrinth/utils'
import { type Component, computed } from 'vue'
import UserBadge from './UserBadge.vue'
const { formatMessage } = useVIntl()
const formatNumber = useFormatNumber()
type EarlyAdopterProjectTypes =
| 'modpack'
| 'resourcepack'
| 'plugin'
| 'datapack'
| 'shader'
| 'server'
type BadgeCriterion =
| {
type: 'earliest_project_date'
project_type: EarlyAdopterProjectTypes
cutoff: Date
}
| {
type: 'join_date'
cutoff: Date
}
| {
type: 'badge'
bitflag: number
}
| {
type: 'midas'
}
| {
type: 'pride'
}
| {
type: 'role'
role: Labrinth.Users.v3.Role
}
type Badge = {
icon: Component
name: MessageDescriptor
about: MessageDescriptor[]
criteria: BadgeCriterion[] // if any criterion matches, the badge will apply (OR logic)
link?: {
href: string
message: MessageDescriptor
}
}
const BADGES = [
{
icon: StaffBadge,
name: defineMessage({
id: 'user.profile.badge.staff.name',
defaultMessage: 'Modrinth Team',
}),
about: [
defineMessage({
id: 'user.profile.badge.staff.about.1',
defaultMessage: `This user works for Modrinth.`,
}),
],
criteria: [
{
type: 'role',
role: 'admin',
},
{
type: 'role',
role: 'moderator',
},
],
},
{
icon: ModeratorBadge,
name: defineMessage({
id: 'user.profile.badge.moderator.name',
defaultMessage: 'Content Moderator',
}),
about: [
defineMessage({
id: 'user.profile.badge.moderator.about.1',
defaultMessage: `This user works for Modrinth as a Content Moderator.`,
}),
defineMessage({
id: 'user.profile.badge.moderator.about.2',
defaultMessage: `Content Moderators on Modrinth review projects, handle reports, and help keep Modrinth safe.`,
}),
],
criteria: [
{
type: 'role',
role: 'moderator',
},
],
},
{
icon: AlphaBadge,
name: defineMessage({
id: 'user.profile.badge.alpha.name',
defaultMessage: 'Alpha Tester',
}),
about: [
defineMessage({
id: 'user.profile.badge.alpha.about.1',
defaultMessage: `This user has been around since Modrinth Alpha, which ended in November 2020`,
}),
],
criteria: [
{
type: 'badge',
bitflag: BadgeBitflag.ALPHA_TESTER,
},
{
type: 'join_date',
cutoff: new Date('2020-11-30T08:00:00.000Z'),
},
],
},
{
icon: BetaBadge,
name: defineMessage({
id: 'user.profile.badge.beta.name',
defaultMessage: 'Beta Tester',
}),
about: [
defineMessage({
id: 'user.profile.badge.beta.about.1',
defaultMessage: `This user has been around since Modrinth Beta, which ended in February 2022.`,
}),
],
criteria: [
{
type: 'join_date',
cutoff: new Date('2022-02-27T08:00:00.000Z'),
},
],
link: {
href: 'https://modrinth.com/news/article/modrinth-beta/',
message: defineMessage({
id: 'user.profile.badge.beta.link',
defaultMessage: `Click to read about the launch of Modrinth Beta.`,
}),
},
},
{
icon: PlusBadge,
name: defineMessage({
id: 'user.profile.badge.plus.name',
defaultMessage: 'Modrinth+ Member',
}),
about: [
defineMessage({
id: 'user.profile.badge.plus.about.1',
defaultMessage: `This user is going the extra mile to support Modrinth and the creators on the platform.`,
}),
],
criteria: [
{
type: 'badge',
bitflag: BadgeBitflag.MIDAS,
},
{
type: 'midas',
},
],
link: {
href: 'https://modrinth.com/plus',
message: defineMessage({
id: 'user.profile.badge.plus.link',
defaultMessage: `Click to learn more about how you can become a member.`,
}),
},
},
{
icon: PrideBadge,
name: defineMessage({
id: 'user.profile.badge.pride.name',
defaultMessage: 'Pride Fundraiser Supporter',
}),
about: [
defineMessage({
id: 'user.profile.badge.pride.about.1',
defaultMessage: `This user participated in at least one of Modrinth's Pride fundraisers for the LGBTQ+ community.`,
}),
],
criteria: [
{
type: 'pride',
},
],
link: {
href: 'https://modrinth.com/pride',
message: defineMessage({
id: 'user.profile.badge.pride.link',
defaultMessage: `Click to visit our latest Pride fundraiser.`,
}),
},
},
{
icon: EarlyModpackBadge,
name: defineMessage({
id: 'user.profile.badge.early-modpack-adopter.name',
defaultMessage: 'Early Modpack Adopter',
}),
about: [
defineMessage({
id: 'user.profile.badge.early-modpack-adopter.about.1',
defaultMessage: `This user helped us test Modpack projects on Modrinth before we launched them in May 2022.`,
}),
],
criteria: [
{
type: 'earliest_project_date',
project_type: 'modpack',
cutoff: new Date('2022-05-23T00:57:00.000Z'),
},
{
type: 'badge',
bitflag: BadgeBitflag.EARLY_MODPACK_ADOPTER,
},
],
},
{
icon: EarlyResourcepackBadge,
name: defineMessage({
id: 'user.profile.badge.early-resourcepack-adopter.name',
defaultMessage: 'Early Resource Pack Adopter',
}),
about: [
defineMessage({
id: 'user.profile.badge.early-resourcepack-adopter.about.1',
defaultMessage: `This user helped us test Resource Pack projects on Modrinth before we launched them in August 2022.`,
}),
],
criteria: [
{
type: 'earliest_project_date',
project_type: 'resourcepack',
cutoff: new Date('2022-08-27T23:03:00.000Z'),
},
{
type: 'badge',
bitflag: BadgeBitflag.EARLY_RESPACK_ADOPTER,
},
],
},
{
icon: EarlyPluginBadge,
name: defineMessage({
id: 'user.profile.badge.early-plugin-adopter.name',
defaultMessage: 'Early Plugin Adopter',
}),
about: [
defineMessage({
id: 'user.profile.badge.early-plugin-adopter.about.1',
defaultMessage: `This user helped us test Plugin projects on Modrinth before we launched them in August 2022.`,
}),
],
criteria: [
{
type: 'earliest_project_date',
project_type: 'plugin',
cutoff: new Date('2022-08-27T23:03:00.000Z'),
},
{
type: 'badge',
bitflag: BadgeBitflag.EARLY_PLUGIN_ADOPTER,
},
],
},
{
icon: EarlyDatapackBadge,
name: defineMessage({
id: 'user.profile.badge.early-datapack-adopter.name',
defaultMessage: 'Early Data Pack Adopter',
}),
about: [
defineMessage({
id: 'user.profile.badge.early-datapack-adopter.about.1',
defaultMessage: `This user helped us test Data Pack projects on Modrinth before we launched them in January 2023.`,
}),
],
criteria: [
{
type: 'earliest_project_date',
project_type: 'datapack',
cutoff: new Date('2023-01-08T02:00:00.000Z'),
},
],
},
{
icon: EarlyShadersBadge,
name: defineMessage({
id: 'user.profile.badge.early-shader-adopter.name',
defaultMessage: 'Early Shader Adopter',
}),
about: [
defineMessage({
id: 'user.profile.badge.early-shader-adopter.about.1',
defaultMessage: `This user helped us test Shader projects on Modrinth before we launched them in January 2023.`,
}),
],
criteria: [
{
type: 'earliest_project_date',
project_type: 'shader',
cutoff: new Date('2023-01-08T02:00:00.000Z'),
},
],
},
{
icon: EarlyServersBadge,
name: defineMessage({
id: 'user.profile.badge.early-server-adopter.name',
defaultMessage: 'Early Server Adopter',
}),
about: [
defineMessage({
id: 'user.profile.badge.early-server-adopter.about.1',
defaultMessage: `This user helped us test Server projects on Modrinth before we launched them in March 2026.`,
}),
],
criteria: [
{
type: 'earliest_project_date',
project_type: 'server',
cutoff: new Date('2026-03-04T01:33:00.000Z'),
},
],
},
{
icon: EarlyHostingBadge,
name: defineMessage({
id: 'user.profile.badge.hosting-alpha.name',
defaultMessage: 'Modrinth Hosting Alpha Tester',
}),
about: [
defineMessage({
id: 'user.profile.badge.hosting-alpha.about.1',
defaultMessage: `This user participated in a closed alpha test of Modrinth Hosting before we launched Modrinth Hosting Beta in November 2024`,
}),
],
criteria: [], // TODO: Add badge on backend for Hosting Alpha Tester
},
] satisfies Badge[]
const DOWNLOAD_BADGES = [
{
icon: Downloads1mBadge,
threshold: 1_000_000,
},
{
icon: Downloads10mBadge,
threshold: 10_000_000,
},
{
icon: Downloads25mBadge,
threshold: 25_000_000,
},
{
icon: Downloads50mBadge,
threshold: 50_000_000,
},
{
icon: Downloads100mBadge,
threshold: 100_000_000,
},
{
icon: Downloads250mBadge,
threshold: 250_000_000,
},
{
icon: Downloads500mBadge,
threshold: 500_000_000,
},
].sort((a, b) => b.threshold - a.threshold)
const props = defineProps<{
role: Labrinth.Users.v2.Role
badges: number
hasMidas?: boolean
hasPride?: boolean
downloads: number
joinDate: Date
earliestProjectByType: Record<EarlyAdopterProjectTypes, Date>
}>()
const downloadsBadge = computed(() => {
return DOWNLOAD_BADGES.find((badge) => props.downloads >= badge.threshold)
})
const messages = defineMessages({
title: {
id: 'profile.label.badges',
defaultMessage: 'Badges',
},
downloadsBadgeName: {
id: 'user.profile.badge.downloads.name',
defaultMessage: '{download_sum} Downloads',
},
downloadsBadgeAbout1: {
id: 'user.profile.badge.downloads.about.1',
defaultMessage: `This user's projects have collectively achieved {download_sum} downloads.`,
},
})
function passesCriterion(criterion: BadgeCriterion) {
switch (criterion.type) {
case 'role': {
return props.role === criterion.role
}
case 'badge': {
return props.badges & criterion.bitflag
}
case 'midas': {
return props.hasMidas === true
}
case 'pride': {
return props.hasPride === true
}
case 'join_date': {
return props.joinDate < criterion.cutoff
}
case 'earliest_project_date': {
const date = props.earliestProjectByType[criterion.project_type]
return date && date < criterion.cutoff
}
default: {
return false
}
}
}
const earnedBadges = computed(() => {
const badges: Badge[] = []
loopingBadges: for (const badge of BADGES) {
for (const criterion of badge.criteria) {
if (passesCriterion(criterion)) {
badges.push(badge)
continue loopingBadges
}
}
}
return badges
})
</script>
<template>
<div v-if="earnedBadges.length > 0 || !!downloadsBadge" class="flex flex-col">
<h2 class="text-lg text-contrast m-0 mb-2">
{{ formatMessage(messages.title) }}
</h2>
<div class="grid grid-cols-[repeat(auto-fill,minmax(64px,1fr))] gap-2">
<UserBadge
v-for="badge in earnedBadges"
:key="badge.name.id"
:name="badge.name"
:icon="badge.icon"
:about="badge.about"
:link="badge.link"
/>
<UserBadge
v-if="downloadsBadge"
:name="messages.downloadsBadgeName"
:icon="downloadsBadge.icon"
:about="[messages.downloadsBadgeAbout1]"
:values="{ download_sum: formatNumber(downloadsBadge.threshold) }"
/>
</div>
</div>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as UserBadges } from './UserBadges.vue'