mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 17:14:50 +00:00
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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user