feat: pride 2026 frontend (#6205)

* feat: pride 2026 banner app sidebar

* feat: use ProgressBar component

* feat: pride skins

* feat: pride skins

* feat: blog post

* fix: blogpost

* fix: pride skin condition

* fix: types

* fix: show logic

* fix: qa

* fix: lint

* fix: unused var
This commit is contained in:
Calum H.
2026-05-31 17:43:41 +01:00
committed by GitHub
parent 34b87991bc
commit 325926ad9b
31 changed files with 553 additions and 31 deletions
+19 -4
View File
@@ -84,6 +84,7 @@ import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
@@ -101,6 +102,7 @@ import { list } from '@/helpers/profile.js'
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
import { hasActivePride26Midas, hasMidasBadge } from '@/helpers/user-campaigns.ts'
import {
areUpdatesEnabled,
enqueueUpdateForInstallation,
@@ -134,6 +136,7 @@ const route = useRoute()
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
const credentials = ref()
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
@@ -144,6 +147,9 @@ const forceSidebar = computed(
)
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
const prideFundraiserEnabled = computed(
() => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
)
const hostingIntercomIdentityKey = computed(() => {
const rawServerId = route.params.id
const serverId = Array.isArray(rawServerId) ? rawServerId[0] : rawServerId
@@ -192,6 +198,12 @@ const tauriApiClient = new TauriModrinthClient({
],
})
provideModrinthClient(tauriApiClient)
const { data: authenticatedModrinthUser } = useQuery({
queryKey: computed(() => ['authenticated-user', 'campaigns', credentials.value?.user?.id]),
queryFn: () => tauriApiClient.labrinth.users_v3.getAuthenticated(),
enabled: () => !!credentials.value?.session,
retry: false,
})
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
@@ -676,12 +688,11 @@ async function logOut() {
await fetchCredentials()
}
const MIDAS_BITFLAG = 1 << 0
const hasPlus = computed(
() =>
credentials.value &&
credentials.value.user &&
(credentials.value.user.badges & MIDAS_BITFLAG) === MIDAS_BITFLAG,
!!credentials.value?.user &&
(hasMidasBadge(credentials.value.user) ||
hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
)
const showAd = computed(
@@ -1479,6 +1490,10 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
</suspense>
</div>
<PrideFundraiserBanner
v-if="prideFundraiserEnabled"
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
/>
<div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">News</h3>
<div class="space-y-4 flex flex-col items-center w-full">
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { CalendarIcon, UsersIcon, XIcon } from '@modrinth/assets'
import { injectModrinthClient, ProgressBar } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref } from 'vue'
const DISMISSED_STORAGE_KEY = 'pride-fundraiser-2026-dismissed'
const client = injectModrinthClient()
const dismissed = ref(localStorage.getItem(DISMISSED_STORAGE_KEY) === 'true')
const { data: campaignInfo } = useQuery({
queryKey: ['campaign', 'pride-26'],
queryFn: () => client.labrinth.campaign_internal.getPride26(),
enabled: () => !dismissed.value,
staleTime: 15 * 60 * 1000,
refetchInterval: 15 * 60 * 1000,
retry: false,
})
const shouldShowBanner = computed(
() => !dismissed.value && Number(campaignInfo.value?.target_usd) > 0,
)
async function openPrideFundraiser() {
await openUrl('https://modrinth.com/pride')
}
function dismissBanner() {
dismissed.value = true
localStorage.setItem(DISMISSED_STORAGE_KEY, 'true')
}
function formatUsd(amount: string | number) {
return Number(amount).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
})
}
function daysLeft() {
return Math.max(
0,
Math.ceil((new Date('2026-07-01T00:00:00Z').getTime() - Date.now()) / (24 * 60 * 60 * 1000)),
)
}
</script>
<template>
<div v-if="shouldShowBanner && campaignInfo">
<section
role="link"
tabindex="0"
class="flex w-full cursor-pointer flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-button-bg p-3 text-primary transition-[border-color,filter] hover:border-surface-6 hover:brightness-125 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
aria-label="Open Pride fundraiser"
@click="openPrideFundraiser"
@keydown.enter="openPrideFundraiser"
@keydown.space.prevent="openPrideFundraiser"
>
<div class="flex w-full items-center justify-between gap-2">
<h2 class="m-0 min-w-0 truncate text-base font-semibold text-contrast">
Pride Fundraiser 2026
</h2>
<button
type="button"
class="m-0 flex size-5 shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-primary transition-colors hover:text-contrast focus-visible:text-contrast"
aria-label="Dismiss Pride fundraiser"
@click.stop="dismissBanner"
@keydown.stop
>
<XIcon aria-hidden="true" class="size-5" />
</button>
</div>
<div class="h-px w-full bg-surface-5" />
<div class="flex w-full flex-col gap-2.5">
<div class="flex items-end gap-1 whitespace-nowrap">
<span class="text-base font-semibold leading-5 text-contrast">
{{ formatUsd(campaignInfo.total_donations_usd) }}
</span>
<span class="text-xs font-medium leading-4">
of {{ formatUsd(campaignInfo.target_usd) }}
</span>
</div>
<ProgressBar
class="pride-fundraiser-banner__progress"
:progress="Number(campaignInfo.total_donations_usd)"
:max="Number(campaignInfo.target_usd)"
color="purple"
full-width
:gradient-border="false"
:aria-label="`${formatUsd(campaignInfo.total_donations_usd)} of ${formatUsd(
campaignInfo.target_usd,
)} raised`"
/>
<div class="flex flex-wrap items-center gap-2 text-xs font-medium leading-4">
<span class="flex items-center gap-1">
<UsersIcon aria-hidden="true" class="size-4 shrink-0" />
{{ campaignInfo.num_donators.toLocaleString('en-US') }}
{{ campaignInfo.num_donators === 1 ? 'supporter' : 'supporters' }}
</span>
<span class="flex items-center gap-1">
<CalendarIcon aria-hidden="true" class="size-4 shrink-0" />
{{ daysLeft() }} {{ daysLeft() === 1 ? 'day left' : 'days left' }}
</span>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.pride-fundraiser-banner__progress :deep(.progress-bar) {
background: linear-gradient(
90deg,
var(--color-red) 0%,
var(--color-orange) 20%,
var(--color-green) 50%,
var(--color-blue) 75%,
var(--color-purple) 100%
);
}
</style>
@@ -393,7 +393,6 @@ const messages = defineMessages({
<FriendsSection
v-if="pendingFriends.length > 0"
:is-searching="!!search"
open-by-default
:friends="pendingFriends"
:heading="formatMessage(messages.pending)"
:remove-friend="removeFriend"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
@@ -11,6 +11,7 @@ import {
useVIntl,
} from '@modrinth/ui'
import { useElementSize, useWindowSize } from '@vueuse/core'
import { Tooltip } from 'floating-vue'
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
@@ -24,6 +25,7 @@ type AddSkinButtonRef = SkinLikeTextButtonExpose | SkinLikeTextButtonExpose[]
interface DefaultSkinSection {
title: string
infoTooltip?: string
skins: Skin[]
}
@@ -31,6 +33,7 @@ interface SkinSection {
key: string
title: string
kind: SkinSectionKind
infoTooltip?: string
skins: Skin[]
}
@@ -145,6 +148,7 @@ const sections = computed<SkinSection[]>(() => [
key: defaultSkinSectionKey(section.title),
title: section.title,
kind: 'default' as const,
infoTooltip: section.infoTooltip,
skins: section.skins,
})),
])
@@ -330,6 +334,24 @@ defineExpose({ getAddSkinButtonElement })
<span class="min-w-0 text-xl font-semibold leading-7 text-primary">
{{ section.title }}
</span>
<Tooltip
v-if="section.infoTooltip"
theme="dismissable-prompt"
placement="top"
:triggers="['hover', 'focus']"
>
<span
class="inline-flex size-6 shrink-0 items-center justify-center text-secondary transition-colors group-hover:text-primary"
@click.stop
>
<UnknownIcon class="size-5" />
</span>
<template #popper>
<p class="m-0 max-w-96 text-wrap text-sm font-medium leading-tight">
{{ section.infoTooltip }}
</p>
</template>
</Tooltip>
</template>
<div
@@ -0,0 +1,23 @@
import type { Labrinth } from '@modrinth/api-client'
const MIDAS_BITFLAG = 1 << 0
const PRIDE_26_MIDAS_DURATION_MS = 30 * 24 * 60 * 60 * 1000
type Pride26Campaign = Labrinth.Users.v3.Pride26CampaignDonation | null | undefined
export function hasMidasBadge(user?: { badges?: number } | null) {
return !!user?.badges && (user.badges & MIDAS_BITFLAG) === MIDAS_BITFLAG
}
export function hasPride26Badge(campaign: Pride26Campaign) {
return campaign?.has_badge === true
}
export function hasActivePride26Midas(campaign: Pride26Campaign, now = Date.now()) {
if (campaign?.has_midas !== true) {
return false
}
const donatedAt = new Date(campaign.last_donated_at).getTime()
return Number.isFinite(donatedAt) && donatedAt + PRIDE_26_MIDAS_DURATION_MS > now
}
@@ -431,6 +431,15 @@
"app.skins.section.minecon-earth-2017": {
"message": "MINECON Earth 2017"
},
"app.skins.section.modrinth": {
"message": "Modrinth"
},
"app.skins.section.modrinth-pride": {
"message": "Modrinth Pride"
},
"app.skins.section.modrinth-pride.tooltip": {
"message": "You received these skins for donating to a Modrinth Pride fundraiser during Pride Month."
},
"app.skins.section.mounts-of-mayhem": {
"message": "Mounts of Mayhem"
},
+63 -5
View File
@@ -13,11 +13,14 @@ import {
commonMessages,
ConfirmModal,
defineMessages,
injectAuth,
injectModrinthClient,
injectNotificationManager,
SkinPreviewRenderer,
useVIntl,
} from '@modrinth/ui'
import { arrayBufferToBase64 } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { type DragDropEvent, getCurrentWebview } from '@tauri-apps/api/webview'
import { computedAsync } from '@vueuse/core'
import type { Ref } from 'vue'
@@ -44,6 +47,7 @@ import {
normalize_skin_texture,
remove_custom_skin,
} from '@/helpers/skins.ts'
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
import { handleSevereError } from '@/store/error'
import { useTheming } from '@/store/state'
@@ -53,11 +57,25 @@ type VirtualSkinSectionListExpose = {
}
const PENDING_SKIN_REFRESH_DELAY_MS = 11_000
const DEFAULT_SKIN_SECTION_SORT_ORDER = ['Default skins', 'Modrinth Pride']
const messages = defineMessages({
skinSelectorTitle: {
id: 'app.skins.title',
defaultMessage: 'Skin selector',
},
modrinthPrideSection: {
id: 'app.skins.section.modrinth-pride',
defaultMessage: 'Modrinth Pride',
},
modrinthPrideTooltip: {
id: 'app.skins.section.modrinth-pride.tooltip',
defaultMessage:
'You received these skins for donating to a Modrinth Pride fundraiser during Pride Month.',
},
modrinthSection: {
id: 'app.skins.section.modrinth',
defaultMessage: 'Modrinth',
},
defaultSkinsSection: {
id: 'app.skins.section.default-skins',
defaultMessage: 'Default skins',
@@ -157,6 +175,8 @@ const skinSectionList = useTemplateRef<VirtualSkinSectionListExpose>('skinSectio
const { formatMessage } = useVIntl()
const notifications = injectNotificationManager()
const { addNotification, handleError } = notifications
const auth = injectAuth()
const client = injectModrinthClient()
const themeStore = useTheming()
const skins = ref<Skin[]>([])
@@ -180,22 +200,42 @@ const savedSkins = computed(() => {
return []
}
})
const defaultSkins = computed(() => filterDefaultSkins(skins.value))
const { data: modrinthUser } = useQuery({
queryKey: computed(() => ['authenticated-user', 'campaigns', auth.user.value?.id]),
queryFn: () => client.labrinth.users_v3.getAuthenticated(),
enabled: () => !!auth.session_token.value,
retry: false,
})
const hasModrinthPrideCampaign = computed(
() => !!auth.session_token.value && hasPride26Badge(modrinthUser.value?.campaigns?.pride_26),
)
const defaultSkins = computed(() =>
filterDefaultSkins(skins.value).filter(
(skin) => skin.section !== 'Modrinth Pride' || hasModrinthPrideCampaign.value,
),
)
const defaultSkinSections = computed(() => {
const sections = new Map<string, Skin[]>()
for (const skin of defaultSkins.value) {
const sectionTitle = getDefaultSkinSectionTitle(skin.section)
const sectionSkins = sections.get(sectionTitle)
const section = skin.section ?? 'Default skins'
const sectionSkins = sections.get(section)
if (sectionSkins) {
sectionSkins.push(skin)
} else {
sections.set(sectionTitle, [skin])
sections.set(section, [skin])
}
}
return Array.from(sections, ([title, skins]) => ({ title, skins }))
return Array.from(sections, ([section, skins]) => ({
section,
title: getDefaultSkinSectionTitle(section),
infoTooltip: getDefaultSkinSectionInfoTooltip(section),
skins,
})).sort(
(a, b) => getDefaultSkinSectionSortIndex(a.section) - getDefaultSkinSectionSortIndex(b.section),
)
})
const currentCape = computed(() => {
@@ -303,6 +343,10 @@ function isMinecraftSkinRateLimitError(error: unknown) {
function getDefaultSkinSectionTitle(section?: string) {
switch (section) {
case 'Modrinth Pride':
return formatMessage(messages.modrinthPrideSection)
case 'Modrinth':
return formatMessage(messages.modrinthSection)
case 'MINECON Earth 2017':
return formatMessage(messages.mineconEarth2017Section)
case 'Builders & Biomes':
@@ -326,6 +370,20 @@ function getDefaultSkinSectionTitle(section?: string) {
}
}
function getDefaultSkinSectionInfoTooltip(section: string) {
switch (section) {
case 'Modrinth Pride':
return formatMessage(messages.modrinthPrideTooltip)
default:
return undefined
}
}
function getDefaultSkinSectionSortIndex(section: string) {
const index = DEFAULT_SKIN_SECTION_SORT_ORDER.indexOf(section)
return index === -1 ? DEFAULT_SKIN_SECTION_SORT_ORDER.length : index
}
function changeSkin(newSkin: Skin) {
selectedSkin.value = newSkin
}
+1
View File
@@ -9,6 +9,7 @@ export const DEFAULT_FEATURE_FLAGS = {
server_ram_as_bytes_always_on: false,
always_show_app_controls: false,
skip_unknown_pack_warning: false,
pride_fundraiser: true,
i18n_debug: false,
}
+4 -4
View File
@@ -75,10 +75,10 @@ export default defineNuxtConfig({
},
ssr: {
// https://github.com/Akryum/floating-vue/issues/809#issuecomment-1002996240
noExternal: ['v-tooltip'],
optimizeDeps: {
include: ['vue-router'],
},
noExternal: ['floating-vue', '@floating-ui/core', '@floating-ui/dom'],
},
optimizeDeps: {
include: ['vue-router', 'floating-vue', '@floating-ui/dom'],
},
define: {
global: {},
Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 891 KiB

@@ -1,5 +1,12 @@
{
"articles": [
{
"title": "Pride 2026 Fundraiser: Matching up to $5,000",
"summary": "Celebrating our community and working together to make a difference.",
"thumbnail": "https://modrinth.com/news/article/pride-campaign-2026/thumbnail.webp",
"date": "2026-06-01T16:00:00.000Z",
"link": "https://modrinth.com/news/article/pride-campaign-2026"
},
{
"title": "Project Analytics are good now",
"summary": "Get deeper insights into how people are using your projects with breakdowns, filtering, and more!",
File diff suppressed because one or more lines are too long