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,18 +1,18 @@
/*
Cards and body styling
*/
// CARDS
.base-card {
padding: var(--spacing-card-lg);
padding: 1rem;
position: relative;
min-height: var(--font-size-2xl);
background-color: var(--color-raised-bg);
border-radius: var(--size-rounded-card);
background-color: var(--surface-3);
border-radius: var(--radius-lg);
border: 1px solid var(--surface-4);
margin-bottom: var(--spacing-card-md);
margin-bottom: var(--gap-md);
outline: 2px solid transparent;
outline-offset: -2px;
box-shadow: var(--shadow-card);
.card__overlay {
position: absolute;
@@ -25,6 +25,17 @@
z-index: 2;
}
&:where(&.warning, &.information) {
padding: 1.5rem;
line-height: 1.5;
min-height: 0;
a {
color: var(--color-blue);
text-decoration: underline;
}
}
&.moderation-card {
background-color: var(--color-warning-banner-bg);
}
@@ -3,7 +3,7 @@
<AutoLink
:to="currentAd.link"
:aria-label="currentAd.description"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit] bg-bg-raised"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit] border border-solid border-surface-4 bg-surface-3"
>
<img
:src="currentAd.light"
@@ -19,7 +19,7 @@
/>
</AutoLink>
<div
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl border border-solid border-surface-4 bg-surface-3"
>
<div id="modrinth-rail-1" />
</div>
+2 -1
View File
@@ -1,7 +1,8 @@
<template>
<nav :aria-label="ariaLabel" class="w-full">
<ul
class="card-shadow m-0 flex list-none flex-col items-start gap-1.5 rounded-2xl bg-bg-raised p-4"
class="card-shadow m-0 flex list-none flex-col items-start gap-1.5 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4"
:class="{ 'pt-3': filteredItems?.[0]?.type === 'heading' }"
>
<slot v-if="hasSlotContent" />
+2
View File
@@ -75,6 +75,7 @@ export const initAuth = async (oldToken = null) => {
auth.user = await useBaseFetch(
'user',
{
apiVersion: 3,
headers: {
Authorization: auth.token,
},
@@ -105,6 +106,7 @@ export const initAuth = async (oldToken = null) => {
auth.user = await useBaseFetch(
'user',
{
apiVersion: 3,
headers: {
Authorization: auth.token,
},
+2 -1
View File
@@ -793,6 +793,7 @@ import ModrinthFooter from '~/components/ui/ModrinthFooter.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
import { errors as generatedStateErrors } from '~/generated/state.json'
import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts'
import { hasActiveMidas } from '~/utils/user-membership.ts'
const generatedState = useGeneratedState()
@@ -1096,7 +1097,7 @@ const userMenuOptions = computed(() => {
id: 'plus',
link: '/plus',
color: 'purple',
shown: !flags.value.hidePlusPromoInUserMenu && !isPermission(user.badges, 1 << 0),
shown: !flags.value.hidePlusPromoInUserMenu && !hasActiveMidas(user),
},
{
id: 'servers',
+5 -3
View File
@@ -2,19 +2,21 @@ import { useAppQueryClient } from '~/composables/query-client'
import { useServerModrinthClient } from '~/server/utils/api-client'
export default defineNuxtRouteMiddleware(async (to) => {
if (!to.path.startsWith('/user/') || !to.params.id) {
const userParam = to.params.user ?? to.params.id
const userId = Array.isArray(userParam) ? userParam[0] : userParam
if (!to.path.startsWith('/user/') || !userId) {
return
}
const queryClient = useAppQueryClient()
const authToken = useCookie('auth-token')
const client = useServerModrinthClient({ authToken: authToken.value || undefined })
const userId = to.params.id as string
try {
const user = await queryClient.fetchQuery({
queryKey: ['user', userId],
queryFn: () => client.labrinth.users_v2.get(userId),
queryFn: () => client.labrinth.users_v3.get(userId),
})
if (!user) return
+2 -6
View File
@@ -38,11 +38,7 @@
{{ calculateSavings(price.prices.intervals.monthly, price.prices.intervals.yearly) }}% with
annual billing!
</p>
<ButtonStyled
v-if="auth.user && isPermission(auth.user.badges, 1 << 0)"
color="purple"
size="large"
>
<ButtonStyled v-if="auth.user && hasActiveMidas(auth.user)" color="purple" size="large">
<nuxt-link to="/settings/billing">
<SettingsIcon aria-hidden="true" />
Manage subscription
@@ -95,8 +91,8 @@ import {
import { calculateSavings, getCurrency } from '@modrinth/utils'
import { useBaseFetch } from '@/composables/fetch.js'
import { isPermission } from '@/utils/permissions.ts'
import { products } from '~/generated/state.json'
import { hasActiveMidas } from '~/utils/user-membership.ts'
const { addNotification } = injectNotificationManager()
const formatPrice = useFormatPrice()
+28 -75
View File
@@ -460,8 +460,11 @@
</div>
</div>
<div class="normal-page__sidebar">
<div v-if="organizations?.length > 0" class="card flex-card">
<h2 class="text-lg text-contrast">
<div
v-if="organizations?.length > 0"
class="mb-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4 pt-3"
>
<h2 class="m-0 mb-2 text-lg text-contrast">
{{ formatMessage(messages.profileOrganizations) }}
</h2>
<div class="flex flex-wrap gap-2">
@@ -476,24 +479,16 @@
</nuxt-link>
</div>
</div>
<div v-if="badges.length > 0" class="card flex-card">
<h2 class="text-lg text-contrast">
{{ formatMessage(messages.profileBadges) }}
</h2>
<div class="flex flex-wrap gap-2">
<div v-for="badge in badges" :key="badge">
<StaffBadge v-if="badge === 'staff'" class="h-14 w-14" />
<ModBadge v-else-if="badge === 'mod'" class="h-14 w-14" />
<nuxt-link v-else-if="badge === 'plus'" to="/plus">
<PlusBadge class="h-14 w-14" />
</nuxt-link>
<TenMClubBadge v-else-if="badge === '10m-club'" class="h-14 w-14" />
<EarlyAdopterBadge v-else-if="badge === 'early-adopter'" class="h-14 w-14" />
<AlphaTesterBadge v-else-if="badge === 'alpha-tester'" class="h-14 w-14" />
<BetaTesterBadge v-else-if="badge === 'beta-tester'" class="h-14 w-14" />
</div>
</div>
</div>
<UserBadges
:downloads="sumDownloads"
:join-date="joinDate"
:role="user.role"
:badges="user.badges"
:has-midas="hasActiveMidas(user)"
:has-pride="hasPride26Badge(user)"
:earliest-project-by-type="earliestProjectByType"
class="mb-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4 pt-3"
/>
<AdPlaceholder v-if="!auth.user" />
</div>
</div>
@@ -540,6 +535,7 @@ import {
useCompactNumber,
useFormatDateTime,
useFormatNumber,
UserBadges,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
@@ -547,19 +543,13 @@ import { isAdmin, isStaff, UserBadge } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { onServerPrefetch } from 'vue'
import TenMClubBadge from '~/assets/images/badges/10m-club.svg?component'
import AlphaTesterBadge from '~/assets/images/badges/alpha-tester.svg?component'
import BetaTesterBadge from '~/assets/images/badges/beta-tester.svg?component'
import EarlyAdopterBadge from '~/assets/images/badges/early-adopter.svg?component'
import ModBadge from '~/assets/images/badges/mod.svg?component'
import PlusBadge from '~/assets/images/badges/plus.svg?component'
import StaffBadge from '~/assets/images/badges/staff.svg?component'
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
import { reportUser } from '~/utils/report-helpers.ts'
import { hasActiveMidas, hasPride26Badge } from '~/utils/user-membership.ts'
const data = useNuxtApp()
const route = useNativeRoute()
@@ -743,7 +733,7 @@ const {
suspense: userSuspense,
} = useQuery({
queryKey: computed(() => ['user', userId]),
queryFn: () => client.labrinth.users_v2.get(userId),
queryFn: () => client.labrinth.users_v3.get(userId),
})
watch(
@@ -859,57 +849,21 @@ const sumDownloads = computed(() => {
})
const joinDate = computed(() => new Date(user.value.created))
const MODRINTH_BETA_END_DATE = new Date('2022-02-27T08:00:00.000Z')
const MODRINTH_ALPHA_END_DATE = new Date('2020-11-30T08:00:00.000Z')
const badges = computed(() => {
const badges = []
if (user.value.role === 'admin') {
badges.push('staff')
}
if (user.value.role === 'moderator') {
badges.push('mod')
}
if (isPermission(user.value.badges, 1 << 0)) {
badges.push('plus')
}
if (sumDownloads.value > 10000000) {
badges.push('10m-club')
}
if (
isPermission(user.value.badges, 1 << 1) ||
isPermission(user.value.badges, 1 << 2) ||
isPermission(user.value.badges, 1 << 3)
) {
badges.push('early-adopter')
}
if (isPermission(user.value.badges, 1 << 4) || joinDate.value < MODRINTH_ALPHA_END_DATE) {
badges.push('alpha-tester')
} else if (isPermission(user.value.badges, 1 << 4) || joinDate.value < MODRINTH_BETA_END_DATE) {
badges.push('beta-tester')
}
if (isPermission(user.value.badges, 1 << 5)) {
badges.push('contributor')
}
if (isPermission(user.value.badges, 1 << 6)) {
badges.push('translator')
}
return badges
})
async function copyId() {
await navigator.clipboard.writeText(user.value.id)
}
const earliestProjectByType = computed(() => {
const obj = {}
for (const project of projects.value ?? []) {
obj[project.project_type] = new Date(project.published)
}
return obj
})
async function copyPermalink() {
await navigator.clipboard.writeText(`${config.public.siteUrl}/user/${user.value.id}`)
}
@@ -1012,7 +966,6 @@ export default defineNuxtComponent({
}
.description {
// Grow to take up remaining space
flex-grow: 1;
color: var(--color-text);
+3 -3
View File
@@ -1,5 +1,5 @@
import type { Labrinth } from '@modrinth/api-client'
import { type AuthProvider, provideAuth } from '@modrinth/ui'
import { type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
import { ref, watchEffect } from 'vue'
import { getSignInRedirectPath } from '~/composables/auth.js'
@@ -8,7 +8,7 @@ export function setupAuthProvider(auth: Awaited<ReturnType<typeof useAuth>>) {
const router = useRouter()
const route = useRoute()
const sessionToken = ref<string | null>(null)
const user = ref<Labrinth.Users.v2.User | null>(null)
const user = ref<AuthUser | null>(null)
const authProvider: AuthProvider = {
session_token: sessionToken,
@@ -26,7 +26,7 @@ export function setupAuthProvider(auth: Awaited<ReturnType<typeof useAuth>>) {
watchEffect(() => {
sessionToken.value = auth.value.token || null
user.value = (auth.value.user as Labrinth.Users.v2.User | null) ?? null
user.value = (auth.value.user as Labrinth.Users.v3.User | null) ?? null
})
provideAuth(authProvider)
@@ -0,0 +1,40 @@
import { UserBadge } from '@modrinth/utils'
const PRIDE_26_MIDAS_DURATION_MS = 30 * 24 * 60 * 60 * 1000
type Pride26Campaign = {
last_donated_at?: string | null
has_badge?: boolean | null
has_midas?: boolean | null
}
type UserWithMembership = {
badges?: number | null
campaigns?: {
pride_26?: Pride26Campaign | null
} | null
}
export function hasPride26Badge(user?: UserWithMembership | null) {
return user?.campaigns?.pride_26?.has_badge === true
}
export function hasActivePride26Midas(user?: UserWithMembership | null, now = Date.now()) {
const pride26Campaign = user?.campaigns?.pride_26
if (!pride26Campaign?.has_midas || !pride26Campaign.last_donated_at) {
return false
}
const lastDonatedAt = Date.parse(pride26Campaign.last_donated_at)
if (!Number.isFinite(lastDonatedAt)) {
return false
}
return lastDonatedAt + PRIDE_26_MIDAS_DURATION_MS > now
}
export function hasActiveMidas(user?: UserWithMembership | null, now = Date.now()) {
return Boolean((user?.badges ?? 0) & UserBadge.MIDAS) || hasActivePride26Midas(user, now)
}