feat: blocking frontend (#6911)

* feat: blocking api interfaces

* feat: block action on user pages + shared instance flows

* feat: safety settings subpage

* feat: interaction source settings

* feat: finish social settings

* feat: profile settings xplat

* fix: prepr

* feat: click on friends

* default instance -> game options & fix feature flag width

* fix: scroll indicators

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-07-28 20:02:58 +00:00
committed by GitHub
co-authored by Prospector
parent cbb31f31c0
commit f89c116bf8
103 changed files with 2166 additions and 1075 deletions
@@ -1,6 +1,8 @@
<template>
<div class="mx-auto flex flex-col items-center p-6 text-center">
<component :is="illustration" v-if="illustration" class="h-[200px] w-auto" />
<slot name="illustration">
<component :is="illustration" v-if="illustration" class="h-[200px] w-auto" />
</slot>
<div class="flex flex-col items-center gap-1.5">
<span class="text-2xl font-semibold text-contrast">
<slot name="heading">{{ heading }}</slot>
@@ -17,6 +17,7 @@ const props = defineProps<{
ariaLabel?: string
belowModal?: boolean
hideWhenModalOpen?: boolean
inline?: boolean
}>()
const INTERCOM_BUBBLE_GAP = 8
@@ -24,6 +25,7 @@ const INTERCOM_BUBBLE_GAP = 8
const barEl = ref<HTMLElement | null>(null)
const toolbarEl = ref<HTMLElement | null>(null)
const compact = ref(false)
const attentionRequested = ref(false)
const { stackCount } = useModalStack()
const pageContext = injectPageContext(null)
@@ -75,6 +77,7 @@ function updateIntercomBubbleClearance() {
if (
typeof window === 'undefined' ||
props.inline ||
!shown.value ||
stackCount.value > 0 ||
!barEl.value ||
@@ -105,7 +108,7 @@ function updateIntercomBubbleClearance() {
function updateBodyState(isShown = shown.value) {
if (typeof document === 'undefined') return
if (isShown) {
if (isShown && !props.inline) {
visibleFloatingActionBars.add(floatingActionBarId)
} else {
visibleFloatingActionBars.delete(floatingActionBarId)
@@ -149,10 +152,10 @@ watch(
)
watch(
shown,
[shown, () => props.inline],
async (isShown) => {
await nextTick()
updateBodyState(isShown)
updateBodyState(isShown[0])
scheduleIntercomBubbleClearanceUpdate()
},
{ immediate: true },
@@ -187,24 +190,40 @@ onUnmounted(() => {
if (typeof document === 'undefined') return
updateFloatingActionBarBodyClass()
})
async function nudge(): Promise<void> {
attentionRequested.value = false
await nextTick()
attentionRequested.value = true
}
defineExpose({ nudge })
</script>
<template>
<Teleport to="body">
<Teleport to="body" :disabled="inline">
<Transition name="floating-action-bar" appear>
<div
v-if="shown"
ref="barEl"
class="floating-action-bar drop-shadow-2xl fixed p-4 bottom-0"
:style="barStyle"
class="floating-action-bar drop-shadow-2xl"
:class="inline ? 'floating-action-bar--inline z-10' : 'fixed bottom-0 p-4'"
:style="inline ? undefined : barStyle"
aria-live="polite"
>
<div
ref="toolbarEl"
role="toolbar"
:aria-label="ariaLabel"
class="relative overflow-clip flex items-center gap-1.5 rounded-[20px] bg-surface-3 border border-surface-5 border-solid mx-auto md:max-w-[60vw] px-3 py-2.5 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
:class="{ 'bar-compact': compact }"
class="relative overflow-clip flex items-center gap-1.5 rounded-[20px] bg-surface-3 border border-surface-5 border-solid px-3 py-2.5 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
:class="[
{
'bar-compact': compact,
'floating-action-bar-attention': attentionRequested,
},
inline ? 'w-full' : 'mx-auto md:max-w-[60vw]',
]"
@animationend="attentionRequested = false"
>
<slot />
</div>
@@ -220,6 +239,31 @@ onUnmounted(() => {
transition: bottom 0.25s ease-in-out;
}
.floating-action-bar--inline {
left: auto;
right: auto;
}
.floating-action-bar-attention {
animation: floating-action-bar-attention 300ms ease-in-out;
}
@keyframes floating-action-bar-attention {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-0.4rem);
}
50% {
transform: translateX(0.4rem);
}
75% {
transform: translateX(-0.2rem);
}
}
.floating-action-bar-enter-active {
transition:
transform 0.25s cubic-bezier(0.15, 1.4, 0.64, 0.96),
@@ -243,14 +287,20 @@ onUnmounted(() => {
}
@media (any-hover: none) and (max-width: 640px) {
.floating-action-bar {
.floating-action-bar:not(.floating-action-bar--inline) {
bottom: var(--size-mobile-navbar-height);
}
.expanded-mobile-nav .floating-action-bar {
.expanded-mobile-nav .floating-action-bar:not(.floating-action-bar--inline) {
bottom: var(--size-mobile-navbar-height-expanded);
}
}
@media (prefers-reduced-motion: reduce) {
.floating-action-bar-attention {
animation: none;
}
}
</style>
<style>
@@ -1,7 +1,7 @@
<script setup lang="ts" generic="T">
import { HistoryIcon, SaveIcon, SpinnerIcon } from '@modrinth/assets'
import { isEqual } from 'es-toolkit'
import { type Component, computed } from 'vue'
import { type Component, computed, ref } from 'vue'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils'
@@ -24,6 +24,7 @@ const props = withDefaults(
saveLabel?: MessageDescriptor | string
savingLabel?: MessageDescriptor | string
saveIcon?: Component
inline?: boolean
}>(),
{
canReset: true,
@@ -36,6 +37,7 @@ const props = withDefaults(
saveLabel: () => commonMessages.saveButton,
savingLabel: () => commonMessages.savingButton,
saveIcon: SaveIcon,
inline: false,
},
)
@@ -46,10 +48,18 @@ const shown = computed(() =>
function localizeIfPossible(message: MessageDescriptor | string) {
return typeof message === 'string' ? message : formatMessage(message)
}
const actionBar = ref<InstanceType<typeof FloatingActionBar> | null>(null)
function nudge(): void {
void actionBar.value?.nudge()
}
defineExpose({ nudge })
</script>
<template>
<FloatingActionBar :shown="shown">
<FloatingActionBar ref="actionBar" :shown="shown" :inline="inline">
<p class="m-0 font-semibold text-sm md:text-base">{{ localizeIfPossible(text) }}</p>
<div class="ml-auto flex gap-2">
<ButtonStyled v-if="canReset" type="transparent">
@@ -175,6 +175,7 @@ const props = withDefaults(
onHide?: () => void
onAfterHide?: () => void
onShow?: () => void
beforeHide?: () => boolean
mergeHeader?: boolean
scrollable?: boolean
maxContentHeight?: string
@@ -202,6 +203,7 @@ const props = withDefaults(
onHide: () => {},
onAfterHide: () => {},
onShow: () => {},
beforeHide: undefined,
mergeHeader: false,
// TODO: migrate all modals to use scrollable and remove this prop
scrollable: false,
@@ -279,9 +281,12 @@ function show(event?: MouseEvent) {
}, 50)
}
function hide() {
function hide(): boolean {
if (props.disableClose) {
return
return false
}
if (props.beforeHide?.() === false) {
return false
}
props.onHide?.()
resetMousePosition()
@@ -302,6 +307,7 @@ function hide() {
hideTimeout = null
nextTick(() => props.onAfterHide?.())
}, 300)
return true
}
async function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
@@ -28,6 +28,9 @@ const props = withDefaults(
closable?: boolean
onHide?: () => void
onShow?: () => void
beforeHide?: () => boolean
beforeTabChange?: (fromIndex: number, toIndex: number) => boolean
floatingActionBarShown?: boolean
}>(),
{
header: undefined,
@@ -36,6 +39,9 @@ const props = withDefaults(
closable: true,
onHide: undefined,
onShow: undefined,
beforeHide: undefined,
beforeTabChange: undefined,
floatingActionBarShown: false,
},
)
@@ -47,9 +53,18 @@ const scrollContainer = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState, forceCheck } =
useScrollIndicator(scrollContainer)
const sidebarScrollContainer = ref<HTMLElement | null>(null)
const {
showTopFade: showSidebarTopFade,
showBottomFade: showSidebarBottomFade,
checkScrollState: checkSidebarScrollState,
} = useScrollIndicator(sidebarScrollContainer)
const modal = ref<InstanceType<typeof NewModal> | null>(null)
function setTab(index: number) {
if (index === selectedTab.value) return
if (props.beforeTabChange?.(selectedTab.value, index) === false) return
selectedTab.value = index
nextTick(() => forceCheck())
}
@@ -58,8 +73,8 @@ function show(event?: MouseEvent) {
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
function hide(): boolean {
return modal.value?.hide() ?? false
}
function startsCategory(index: number) {
@@ -78,6 +93,7 @@ defineExpose({ show, hide, selectedTab, setTab })
:closable="closable"
:on-hide="onHide"
:on-show="onShow"
:before-hide="beforeHide"
no-padding
>
<template v-if="$slots.title" #title>
@@ -85,38 +101,74 @@ defineExpose({ show, hide, selectedTab, setTab })
</template>
<div class="grid grid-cols-[auto_1fr] p-6 pb-3 pr-0">
<div
class="flex flex-col gap-1 border-solid pr-4 border-0 border-r-[1px] border-divider min-w-[200px]"
class="flex min-w-[200px] max-h-[min(65vh,600px)] flex-col border-0 border-r-[1px] border-solid border-divider pr-4"
>
<template v-for="(tab, index) in visibleTabs" :key="index">
<div class="relative min-h-0 flex-1">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-4"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-4"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showSidebarTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-4 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
v-if="startsCategory(index) && tab.category"
class="px-4 pb-1 pt-2 text-xs font-bold uppercase tracking-wide text-secondary"
ref="sidebarScrollContainer"
class="flex h-full flex-col gap-1 overflow-y-auto"
@scroll="checkSidebarScrollState"
>
{{ formatMessage(tab.category) }}
<template v-for="(tab, index) in visibleTabs" :key="index">
<div
v-if="startsCategory(index) && tab.category"
class="px-4 pb-1 pt-2 text-xs font-bold uppercase tracking-wide text-secondary"
>
{{ formatMessage(tab.category) }}
</div>
<component
:is="tab.href ? 'a' : 'button'"
:href="tab.href ?? undefined"
:target="tab.href ? '_blank' : undefined"
:rel="tab.href ? 'noopener noreferrer' : undefined"
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all no-underline ${!tab.href && selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
@click="!tab.href && setTab(index)"
>
<component :is="tab.icon" class="w-4 h-4 flex-shrink-0" />
<span>{{ formatMessage(tab.name) }}</span>
<span
v-if="tab.badge"
class="rounded-full px-1.5 py-0.5 text-xs font-bold bg-brand-highlight text-brand-green"
>
{{ formatMessage(tab.badge) }}
</span>
<RightArrowIcon v-if="tab.href" class="size-4 ml-auto" />
</component>
</template>
</div>
<component
:is="tab.href ? 'a' : 'button'"
:href="tab.href ?? undefined"
:target="tab.href ? '_blank' : undefined"
:rel="tab.href ? 'noopener noreferrer' : undefined"
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all no-underline ${!tab.href && selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
@click="!tab.href && setTab(index)"
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-16"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-16"
leave-to-class="opacity-0 max-h-0"
>
<component :is="tab.icon" class="w-4 h-4 flex-shrink-0" />
<span>{{ formatMessage(tab.name) }}</span>
<span
v-if="tab.badge"
class="rounded-full px-1.5 py-0.5 text-xs font-bold bg-brand-highlight text-brand-green"
>
{{ formatMessage(tab.badge) }}
</span>
<RightArrowIcon v-if="tab.href" class="size-4 ml-auto" />
</component>
</template>
<div
v-if="showSidebarBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-16 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
<slot name="footer" />
</div>
<div class="relative">
<div class="relative min-h-[min(65vh,600px)]">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
@@ -133,7 +185,8 @@ defineExpose({ show, hide, selectedTab, setTab })
<div
ref="scrollContainer"
class="overflow-y-auto px-6 pb-6 h-screen max-h-[min(65vh,600px)]"
class="absolute inset-0 overflow-y-auto px-6"
:class="floatingActionBarShown ? 'pb-24' : 'pb-6'"
@scroll="checkScrollState"
>
<Suspense>
@@ -157,6 +210,12 @@ defineExpose({ show, hide, selectedTab, setTab })
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-16 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
<div class="pointer-events-none absolute bottom-3 left-6 right-6 z-20">
<div class="pointer-events-auto">
<slot name="floating-action-bar" />
</div>
</div>
</div>
</div>
</NewModal>
@@ -82,6 +82,7 @@ import type { Labrinth } from '@modrinth/api-client'
import {
AffiliateIcon,
BadgeCheckIcon,
BanIcon,
BoxIcon,
CalendarIcon,
ChartIcon,
@@ -123,6 +124,14 @@ const messages = defineMessages({
id: 'profile.button.billing',
defaultMessage: 'Manage user billing',
},
blockButton: {
id: 'profile.button.block',
defaultMessage: 'Block',
},
unblockButton: {
id: 'profile.button.unblock',
defaultMessage: 'Unblock',
},
editRoleButton: {
id: 'profile.button.edit-role',
defaultMessage: 'Edit role',
@@ -175,6 +184,7 @@ const props = withDefaults(
isAdmin?: boolean
isStaff?: boolean
showStaffActions?: boolean
isBlocked?: boolean
projectsCount?: number
downloads?: number
}>(),
@@ -190,6 +200,7 @@ const props = withDefaults(
isAdmin: false,
isStaff: false,
showStaffActions: false,
isBlocked: false,
projectsCount: 0,
downloads: 0,
},
@@ -198,6 +209,7 @@ const props = withDefaults(
const emit = defineEmits<{
manageProjects: []
report: []
block: []
copyId: []
copyPermalink: []
openBilling: []
@@ -236,6 +248,14 @@ const moreActions = computed<TeleportOverflowMenuItem[]>(() => [
color: 'red',
shown: props.authUser?.id !== props.user.id,
},
{
id: 'block',
label: formatMessage(props.isBlocked ? messages.unblockButton : messages.blockButton),
icon: BanIcon,
action: () => emit('block'),
color: 'red',
shown: props.authUser?.id !== props.user.id,
},
{
id: 'copy-id',
label: formatMessage(commonMessages.copyIdButton),