mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
feat: profile settings xplat
This commit is contained in:
@@ -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,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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user