feat: profile settings xplat

This commit is contained in:
Calum H. (IMB11)
2026-07-28 17:36:26 +01:00
parent 65b0e7a5d0
commit 6ef99c5989
22 changed files with 954 additions and 293 deletions
@@ -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,44 @@ 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 +243,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 +291,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,
},
)
@@ -50,6 +56,8 @@ const { showTopFade, showBottomFade, checkScrollState, forceCheck } =
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 +66,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 +86,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>
@@ -133,7 +142,8 @@ defineExpose({ show, hide, selectedTab, setTab })
<div
ref="scrollContainer"
class="absolute inset-0 overflow-y-auto px-6 pb-6"
class="absolute inset-0 overflow-y-auto px-6"
:class="floatingActionBarShown ? 'pb-24' : 'pb-6'"
@scroll="checkScrollState"
>
<Suspense>
@@ -157,6 +167,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>
@@ -500,6 +500,7 @@ const props = withDefaults(
siteUrl?: string
externalNavigation?: boolean
projectLinkMode?: 'website' | 'app'
editProfileLink?: string | (() => void)
onCreateProject?: (event?: MouseEvent) => void
onCreateCollection?: (event?: MouseEvent) => void
}>(),
@@ -511,6 +512,7 @@ const props = withDefaults(
siteUrl: 'https://modrinth.com',
externalNavigation: false,
projectLinkMode: 'website',
editProfileLink: undefined,
onCreateProject: undefined,
onCreateCollection: undefined,
},
@@ -837,7 +839,9 @@ const showCollectionsEmptyState = computed(
)
const normalizedSiteUrl = computed(() => props.siteUrl.replace(/\/$/, ''))
const editProfileLink = computed(() => linkTarget('/settings/profile'))
const editProfileLink = computed(
() => props.editProfileLink ?? linkTarget('/settings/profile'),
)
function externalUrl(path: string): string {
return `${normalizedSiteUrl.value}${path.startsWith('/') ? path : `/${path}`}`
@@ -0,0 +1,380 @@
<template>
<EmptyState
v-if="!auth.user.value"
type="empty"
class="[&>div:last-child]:!mt-6"
:heading="formatMessage(messages.signInRequiredTitle)"
:description="formatMessage(messages.signInRequiredDescription)"
>
<template #illustration>
<div class="relative mb-4 h-[200px]">
<img :src="ThinkingRinthbot" alt="" class="h-full w-auto object-contain" />
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-gradient-to-t from-bg-raised to-transparent"
/>
</div>
</template>
<template #actions>
<ButtonStyled color="brand" size="large">
<button type="button" @click="requestSignIn">
<LogInIcon aria-hidden="true" />
{{ formatMessage(commonMessages.signInButton) }}
</button>
</ButtonStyled>
</template>
</EmptyState>
<div v-else class="flex flex-col gap-4">
<p
class="m-0 text-secondary"
:class="{ 'order-last': disclaimerPosition === 'bottom' }"
>
<IntlFormatted :message-id="messages.description">
<template #profile-link="{ children }">
<RouterLink v-slot="{ href, navigate }" :to="profilePath" custom>
<a
:href="href"
class="text-link"
@click="handleProfileLinkClick($event, navigate)"
>
<component :is="() => children" />
</a>
</RouterLink>
</template>
<template #docs-link="{ children }">
<a href="https://docs.modrinth.com/" target="_blank" class="text-link">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</p>
<hr
v-if="disclaimerPosition === 'top'"
class="m-0 h-px w-full border-none bg-divider"
aria-hidden="true"
/>
<section class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.profilePicture) }}
</h2>
<div class="flex items-center gap-4">
<Avatar
:src="displayedAvatarUrl"
size="md"
circle
:alt="auth.user.value.username"
/>
<div class="flex flex-col gap-2">
<ButtonStyled>
<FileInput
:max-size="262144"
:show-icon="true"
class="button-like !shadow-none"
:prompt="formatMessage(commonMessages.uploadImageButton)"
accept="image/png,image/jpeg,image/gif,image/webp"
@change="showPreviewImage"
>
<UploadIcon aria-hidden="true" />
</FileInput>
</ButtonStyled>
<ButtonStyled v-if="avatarUrl && !pendingAvatarDeletion">
<button type="button" class="!shadow-none" @click="removePreviewImage">
<TrashIcon aria-hidden="true" />
{{ formatMessage(commonMessages.removeImageButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="avatarFile || pendingAvatarDeletion">
<button type="button" class="!shadow-none" @click="resetAvatar">
<UndoIcon aria-hidden="true" />
{{ formatMessage(commonMessages.resetButton) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(commonMessages.usernameLabel) }}
</h2>
<div class="flex items-center gap-2">
<StyledInput
id="username-field"
v-model="current.username"
class="w-full max-w-md"
:error="current.username.length > 39"
/>
<span
v-if="current.username.length >= 30"
class="shrink-0 text-secondary"
:class="{ 'text-red': current.username.length > 39 }"
>
{{ current.username.length }}/39
</span>
</div>
<p class="m-0 text-secondary">
{{ formatMessage(messages.usernameDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.bioTitle) }}
</h2>
<StyledInput
id="bio-field"
v-model="current.bio"
multiline
:error="current.bio.length > 160"
/>
<div class="text-secondary" :class="{ 'text-red': current.bio.length > 160 }">
{{ current.bio.length }}/160
</div>
<p class="m-0 text-secondary">
{{ formatMessage(messages.bioDescription) }}
</p>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { LogInIcon, ThinkingRinthbot, TrashIcon, UndoIcon, UploadIcon } from '@modrinth/assets'
import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { RouterLink } from 'vue-router'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import FileInput from '#ui/components/base/FileInput.vue'
import IntlFormatted from '#ui/components/base/IntlFormatted.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import { defineMessages, useVIntl } from '#ui/composables'
import {
type AuthUser,
injectAuth,
injectNotificationManager,
} from '#ui/providers'
import { commonMessages } from '#ui/utils'
type ProfileFields = {
username: string
bio: string
}
const props = withDefaults(
defineProps<{
patchUser: (userId: string, patch: Partial<ProfileFields>) => Promise<void>
changeAvatar: (userId: string, file: Blob, extension: string) => Promise<void>
deleteAvatar: (userId: string) => Promise<void>
getAuthenticatedUser: () => Promise<AuthUser>
disclaimerPosition?: 'top' | 'bottom'
}>(),
{
disclaimerPosition: 'top',
},
)
const emit = defineEmits<{
profileLinkClick: [event: MouseEvent]
}>()
const auth = injectAuth()
const notificationManager = injectNotificationManager()
const { formatMessage } = useVIntl()
const activeUserId = ref<string | null>(null)
const original = ref<ProfileFields>({ username: '', bio: '' })
const current = ref<ProfileFields>({ username: '', bio: '' })
const avatarUrl = ref<string | null>(null)
const avatarFile = shallowRef<File | null>(null)
const previewImageUrl = ref<string | null>(null)
const pendingAvatarDeletion = ref(false)
const saving = ref(false)
const displayedAvatarUrl = computed(() => {
if (previewImageUrl.value) return previewImageUrl.value
if (pendingAvatarDeletion.value) return null
return avatarUrl.value
})
const profilePath = computed(
() => `/user/${encodeURIComponent(auth.user.value?.username ?? current.value.username)}`,
)
const originalState = computed(() => ({
...original.value,
avatarChanged: false,
}))
const modifiedState = computed(() => ({
...current.value,
avatarChanged: Boolean(avatarFile.value || pendingAvatarDeletion.value),
}))
const hasChanges = computed(
() =>
current.value.username !== original.value.username ||
current.value.bio !== original.value.bio ||
Boolean(avatarFile.value || pendingAvatarDeletion.value),
)
watch(
() => auth.user.value,
(user) => {
if (!user || user.id !== activeUserId.value) {
syncFromUser(user)
}
},
{ immediate: true },
)
function syncFromUser(user: AuthUser | null): void {
revokePreviewImage()
activeUserId.value = user?.id ?? null
original.value = {
username: user?.username ?? '',
bio: user?.bio ?? '',
}
current.value = { ...original.value }
avatarUrl.value = user?.avatar_url ?? null
avatarFile.value = null
pendingAvatarDeletion.value = false
}
function revokePreviewImage(): void {
if (previewImageUrl.value) {
URL.revokeObjectURL(previewImageUrl.value)
previewImageUrl.value = null
}
}
function showPreviewImage(files: File[]): void {
const file = files[0]
if (!file) return
revokePreviewImage()
avatarFile.value = file
previewImageUrl.value = URL.createObjectURL(file)
pendingAvatarDeletion.value = false
}
function removePreviewImage(): void {
revokePreviewImage()
avatarFile.value = null
pendingAvatarDeletion.value = true
}
function resetAvatar(): void {
revokePreviewImage()
avatarFile.value = null
pendingAvatarDeletion.value = false
}
function reset(): void {
current.value = { ...original.value }
resetAvatar()
}
async function requestSignIn(): Promise<void> {
await auth.requestSignIn('')
}
function handleProfileLinkClick(
event: MouseEvent,
navigate: (event?: MouseEvent) => unknown,
): void {
emit('profileLinkClick', event)
if (!event.defaultPrevented) {
navigate(event)
}
}
async function save(): Promise<void> {
const user = auth.user.value
if (!user || saving.value) return
saving.value = true
try {
const patch: Partial<ProfileFields> = {}
if (current.value.username !== original.value.username) {
patch.username = current.value.username
}
if (current.value.bio !== original.value.bio) {
patch.bio = current.value.bio
}
if (Object.keys(patch).length > 0) {
await props.patchUser(user.id, patch)
}
if (pendingAvatarDeletion.value) {
await props.deleteAvatar(user.id)
} else if (avatarFile.value) {
const extension = avatarFile.value.type.split('/').at(-1)
if (!extension) throw new Error('The selected image does not have a valid file type.')
await props.changeAvatar(user.id, avatarFile.value, extension)
}
const refreshedUser = await props.getAuthenticatedUser()
auth.user.value = refreshedUser
syncFromUser(refreshedUser)
} catch {
notificationManager.addNotification({
type: 'error',
title: formatMessage(messages.saveError),
text: formatMessage(messages.saveErrorDescription),
})
} finally {
saving.value = false
}
}
onBeforeUnmount(revokePreviewImage)
defineExpose({
originalState,
modifiedState,
saving,
hasChanges,
reset,
save,
})
const messages = defineMessages({
description: {
id: 'settings.profile.public-information.description',
defaultMessage:
'Your profile information is publicly <profile-link>viewable on Modrinth</profile-link> and through the <docs-link>Modrinth API</docs-link>.',
},
profilePicture: {
id: 'settings.profile.profile-picture.title',
defaultMessage: 'Profile picture',
},
usernameDescription: {
id: 'settings.profile.username.description',
defaultMessage: 'A unique case-insensitive name to identify your profile.',
},
bioTitle: {
id: 'settings.profile.bio.title',
defaultMessage: 'Bio',
},
bioDescription: {
id: 'settings.profile.bio.description',
defaultMessage: 'A short description to tell everyone a little bit about you.',
},
signInRequiredTitle: {
id: 'settings.profile.sign-in-required.title',
defaultMessage: 'Modrinth account required',
},
signInRequiredDescription: {
id: 'settings.profile.sign-in-required.description',
defaultMessage: 'Sign in with a Modrinth account to customize your public profile.',
},
saveError: {
id: 'settings.profile.save-error',
defaultMessage: 'Failed to update profile',
},
saveErrorDescription: {
id: 'settings.profile.save-error-description',
defaultMessage: 'An error occurred while updating your profile. Please try again.',
},
})
</script>
@@ -347,11 +347,12 @@ const messages = defineMessages({
},
sharedInstanceInvitesTitle: {
id: 'settings.social.shared-instance-invites.title',
defaultMessage: 'Shared instance invites',
defaultMessage: 'Invitations',
},
sharedInstanceInvitesDescription: {
id: 'settings.social.shared-instance-invites.description',
defaultMessage: 'Control who can send you invites to shared instances on Modrinth.',
defaultMessage:
'Control who can send you invites to shared instances and Modrinth Hosting panels.',
},
everyone: {
id: 'settings.social.interaction-source.everyone',
+1
View File
@@ -1,3 +1,4 @@
export { default as AccountProfileSettings } from './AccountProfileSettings.vue'
export { default as AccountSocialSettings } from './AccountSocialSettings.vue'
export { default as ServersManageAccessPage } from './hosting/manage/[id]/access/access.vue'
export { default as ServerOnboardingPanelPage } from './hosting/manage/[id]/onboarding.vue'
@@ -15,6 +15,7 @@ import type { StoryObj } from '@storybook/vue3-vite'
import { defineComponent, h, ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import UnsavedChangesPopup from '../../components/base/UnsavedChangesPopup.vue'
import TabbedModal from '../../components/modal/TabbedModal.vue'
function makeTabContent(label: string, lines = 3) {
@@ -152,6 +153,47 @@ export const WithFooter: StoryObj = {
}),
}
export const WithFloatingActionBar: StoryObj = {
render: () => ({
components: { TabbedModal, ButtonStyled, UnsavedChangesPopup },
setup() {
const modalRef = ref<InstanceType<typeof TabbedModal> | null>(null)
const dirty = ref(true)
const tabs = [
{
name: { id: 'general', defaultMessage: 'General' },
icon: InfoIcon,
content: makeTabContent('General', 20),
},
]
return { modalRef, dirty, tabs }
},
template: /* html */ `
<div>
<ButtonStyled color="brand">
<button @click="dirty = true; modalRef?.show()">Open with Floating Action Bar</button>
</ButtonStyled>
<TabbedModal
ref="modalRef"
header="Settings"
:tabs="tabs"
:floating-action-bar-shown="dirty"
>
<template #floating-action-bar>
<UnsavedChangesPopup
:original="{ dirty: false }"
:modified="{ dirty }"
inline
@save="dirty = false"
@reset="dirty = false"
/>
</template>
</TabbedModal>
</div>
`,
}),
}
export const WithBadge: StoryObj = {
render: () => ({
components: { TabbedModal, ButtonStyled },
+2 -2
View File
@@ -1068,8 +1068,8 @@ export const commonSettingsMessages = defineMessages({
defaultMessage: 'Personal access tokens',
},
profile: {
id: 'settings.profile.title',
defaultMessage: 'Public profile',
id: 'settings.profile.navigation-title',
defaultMessage: 'Profile',
},
sessions: {
id: 'settings.sessions.title',