mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 17:44: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,5 +1,35 @@
|
||||
<template>
|
||||
<template v-if="user">
|
||||
<NewModal
|
||||
ref="blockUserModal"
|
||||
:header="formatMessage(messages.blockUserTitle, { username: user.username })"
|
||||
:closable="!isBlockingUser"
|
||||
fade="danger"
|
||||
max-width="500px"
|
||||
>
|
||||
<Admonition type="critical" :header="formatMessage(messages.blockUserAdmonitionTitle)">
|
||||
{{ formatMessage(messages.blockUserAdmonitionBody, { username: user.username }) }}
|
||||
</Admonition>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" :disabled="isBlockingUser" @click="blockUserModal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button type="button" :disabled="isBlockingUser" @click="confirmBlockUser">
|
||||
<SpinnerIcon v-if="isBlockingUser" class="animate-spin" />
|
||||
<BanIcon v-else />
|
||||
{{ formatMessage(messages.blockButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
|
||||
<NewModal
|
||||
v-if="variant === 'web'"
|
||||
ref="editRoleModal"
|
||||
@@ -151,10 +181,12 @@
|
||||
:is-admin="isAdminViewing"
|
||||
:is-staff="isStaffViewing"
|
||||
:show-staff-actions="variant === 'web'"
|
||||
:is-blocked="isBlocked"
|
||||
:projects-count="projects.length"
|
||||
:downloads="sumDownloads"
|
||||
@manage-projects="openPath('/dashboard/projects')"
|
||||
@report="reportProfile"
|
||||
@block="handleBlockAction"
|
||||
@copy-id="copyId"
|
||||
@copy-permalink="copyPermalink"
|
||||
@open-billing="openPath(`/admin/billing/${user.id}`)"
|
||||
@@ -392,6 +424,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
BanIcon,
|
||||
BoxIcon,
|
||||
CheckIcon,
|
||||
GlobeIcon,
|
||||
@@ -411,6 +444,7 @@ import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import AutoLink from '#ui/components/base/AutoLink.vue'
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
@@ -429,7 +463,7 @@ import { defineMessages, useVIntl } from '#ui/composables'
|
||||
import { injectAuth, injectNotificationManager, injectPageContext, injectTags } from '#ui/providers'
|
||||
import { commonMessages, getProjectTypeTitleMessage } from '#ui/utils'
|
||||
|
||||
import { injectUserProfile } from './providers'
|
||||
import { blockedUsersQueryKey, injectUserProfile } from './providers'
|
||||
import {
|
||||
hasActivePride26Midas,
|
||||
hasPride26Badge,
|
||||
@@ -463,6 +497,7 @@ const props = withDefaults(
|
||||
siteUrl?: string
|
||||
externalNavigation?: boolean
|
||||
projectLinkMode?: 'website' | 'app'
|
||||
editProfileLink?: string | (() => void)
|
||||
onCreateProject?: (event?: MouseEvent) => void
|
||||
onCreateCollection?: (event?: MouseEvent) => void
|
||||
}>(),
|
||||
@@ -474,6 +509,7 @@ const props = withDefaults(
|
||||
siteUrl: 'https://modrinth.com',
|
||||
externalNavigation: false,
|
||||
projectLinkMode: 'website',
|
||||
editProfileLink: undefined,
|
||||
onCreateProject: undefined,
|
||||
onCreateCollection: undefined,
|
||||
},
|
||||
@@ -608,6 +644,55 @@ const messages = defineMessages({
|
||||
id: 'profile.role.update-error-description',
|
||||
defaultMessage: 'An error occurred while updating the user role. Please try again.',
|
||||
},
|
||||
blockButton: {
|
||||
id: 'profile.button.block',
|
||||
defaultMessage: 'Block',
|
||||
},
|
||||
unblockUserSuccessTitle: {
|
||||
id: 'profile.unblock-user.success-title',
|
||||
defaultMessage: 'User unblocked',
|
||||
},
|
||||
unblockUserSuccessDescription: {
|
||||
id: 'profile.unblock-user.success-description',
|
||||
defaultMessage: '{username} has been unblocked.',
|
||||
},
|
||||
unblockUserErrorTitle: {
|
||||
id: 'profile.unblock-user.error-title',
|
||||
defaultMessage: 'Failed to unblock user',
|
||||
},
|
||||
unblockUserErrorDescription: {
|
||||
id: 'profile.unblock-user.error-description',
|
||||
defaultMessage: 'An error occurred while unblocking this user. Please try again.',
|
||||
},
|
||||
blockUserTitle: {
|
||||
id: 'profile.block-user.title',
|
||||
defaultMessage: 'Block {username}',
|
||||
},
|
||||
blockUserAdmonitionTitle: {
|
||||
id: 'profile.block-user.admonition-title',
|
||||
defaultMessage: 'Are you sure you want to block this user?',
|
||||
},
|
||||
blockUserAdmonitionBody: {
|
||||
id: 'profile.block-user.admonition-body',
|
||||
defaultMessage:
|
||||
'{username} will not be able to send you friend requests, invite you to shared instances or invite you to Modrinth Hosting servers.',
|
||||
},
|
||||
blockUserSuccessTitle: {
|
||||
id: 'profile.block-user.success-title',
|
||||
defaultMessage: 'User blocked',
|
||||
},
|
||||
blockUserSuccessDescription: {
|
||||
id: 'profile.block-user.success-description',
|
||||
defaultMessage: '{username} has been blocked.',
|
||||
},
|
||||
blockUserErrorTitle: {
|
||||
id: 'profile.block-user.error-title',
|
||||
defaultMessage: 'Failed to block user',
|
||||
},
|
||||
blockUserErrorDescription: {
|
||||
id: 'profile.block-user.error-description',
|
||||
defaultMessage: 'An error occurred while blocking this user. Please try again.',
|
||||
},
|
||||
})
|
||||
|
||||
const userQuery = useQuery({
|
||||
@@ -634,6 +719,12 @@ const collectionsQuery = useQuery({
|
||||
enabled: computed(() => Boolean(props.userId)),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const blockedUsersQuery = useQuery({
|
||||
queryKey: computed(() => blockedUsersQueryKey(auth.user.value?.id)),
|
||||
queryFn: userProfile.getBlockedUsers,
|
||||
enabled: computed(() => Boolean(auth.user.value)),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const user = computed(() => userQuery.data.value)
|
||||
const projects = computed<ResolvedProject[]>(() =>
|
||||
@@ -644,6 +735,9 @@ const projects = computed<ResolvedProject[]>(() =>
|
||||
)
|
||||
const organizations = computed(() => organizationsQuery.data.value ?? [])
|
||||
const collections = computed(() => collectionsQuery.data.value ?? [])
|
||||
const isBlocked = computed(() =>
|
||||
user.value ? (blockedUsersQuery.data.value ?? []).includes(user.value.id) : false,
|
||||
)
|
||||
|
||||
const selectedProjectType = computed(() => {
|
||||
const projectType = props.projectType
|
||||
@@ -742,7 +836,7 @@ 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}`}`
|
||||
@@ -826,8 +920,11 @@ async function retryQueries(): Promise<void> {
|
||||
|
||||
const userDetailsModal = ref<ModalRef | null>(null)
|
||||
const editRoleModal = ref<ModalRef | null>(null)
|
||||
const blockUserModal = ref<ModalRef | null>(null)
|
||||
const selectedRole = ref<Labrinth.Users.v3.Role | null>(null)
|
||||
const isSavingRole = ref(false)
|
||||
const isBlockingUser = ref(false)
|
||||
const isUnblockingUser = ref(false)
|
||||
const roleOptions = [
|
||||
{ value: 'developer', label: 'Developer' },
|
||||
{ value: 'moderator', label: 'Moderator' },
|
||||
@@ -851,6 +948,82 @@ function openRoleEditModal(): void {
|
||||
editRoleModal.value?.show()
|
||||
}
|
||||
|
||||
async function handleBlockAction(): Promise<void> {
|
||||
if (!auth.user.value) {
|
||||
await auth.requestSignIn(route.fullPath)
|
||||
return
|
||||
}
|
||||
|
||||
if (isBlocked.value) {
|
||||
await unblockCurrentUser()
|
||||
return
|
||||
}
|
||||
|
||||
blockUserModal.value?.show()
|
||||
}
|
||||
|
||||
async function confirmBlockUser(): Promise<void> {
|
||||
if (!user.value || isBlockingUser.value) return
|
||||
|
||||
const blockedUser = user.value
|
||||
const authUserId = auth.user.value?.id
|
||||
isBlockingUser.value = true
|
||||
try {
|
||||
await userProfile.blockUser(blockedUser.id)
|
||||
queryClient.setQueryData<Labrinth.BlockedUsers.v3.BlockedUserId[]>(
|
||||
blockedUsersQueryKey(authUserId),
|
||||
(blockedUsers = []) =>
|
||||
blockedUsers.includes(blockedUser.id) ? blockedUsers : [...blockedUsers, blockedUser.id],
|
||||
)
|
||||
blockUserModal.value?.hide()
|
||||
notificationManager.addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.blockUserSuccessTitle),
|
||||
text: formatMessage(messages.blockUserSuccessDescription, {
|
||||
username: blockedUser.username,
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
notificationManager.addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.blockUserErrorTitle),
|
||||
text: formatMessage(messages.blockUserErrorDescription),
|
||||
})
|
||||
} finally {
|
||||
isBlockingUser.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function unblockCurrentUser(): Promise<void> {
|
||||
if (!user.value || isUnblockingUser.value) return
|
||||
|
||||
const blockedUser = user.value
|
||||
const authUserId = auth.user.value?.id
|
||||
isUnblockingUser.value = true
|
||||
try {
|
||||
await userProfile.unblockUser(blockedUser.id)
|
||||
queryClient.setQueryData<Labrinth.BlockedUsers.v3.BlockedUserId[]>(
|
||||
blockedUsersQueryKey(authUserId),
|
||||
(blockedUsers = []) => blockedUsers.filter((userId) => userId !== blockedUser.id),
|
||||
)
|
||||
notificationManager.addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.unblockUserSuccessTitle),
|
||||
text: formatMessage(messages.unblockUserSuccessDescription, {
|
||||
username: blockedUser.username,
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
notificationManager.addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.unblockUserErrorTitle),
|
||||
text: formatMessage(messages.unblockUserErrorDescription),
|
||||
})
|
||||
} finally {
|
||||
isUnblockingUser.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRoleEdit(): void {
|
||||
selectedRole.value = user.value?.role ?? null
|
||||
editRoleModal.value?.hide()
|
||||
|
||||
@@ -11,8 +11,14 @@ export interface UserProfileContext {
|
||||
userId: string,
|
||||
patch: Partial<Pick<Labrinth.Users.v3.User, 'badges' | 'role'>>,
|
||||
) => Promise<void>
|
||||
getBlockedUsers: () => Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]>
|
||||
blockUser: (userId: string) => Promise<void>
|
||||
unblockUser: (userId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const blockedUsersQueryKey = (userId?: string | null) =>
|
||||
['blocked-users', userId ?? null] as const
|
||||
|
||||
export const [injectUserProfile, provideUserProfile] = createContext<UserProfileContext>(
|
||||
'UserProfilePageLayout',
|
||||
'userProfileContext',
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
<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>
|
||||
@@ -0,0 +1,447 @@
|
||||
<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-8">
|
||||
<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.friendRequestsTitle) }}
|
||||
</h2>
|
||||
<Chips
|
||||
v-model="friendRequestSource"
|
||||
:items="friendRequestSourceOptions"
|
||||
:format-label="formatInteractionSource"
|
||||
:disabled-items="friendRequestSourceOptions"
|
||||
:disabled-tooltip="formatMessage(messages.comingSoon)"
|
||||
:capitalize="false"
|
||||
:aria-label="formatMessage(messages.friendRequestsTitle)"
|
||||
/>
|
||||
<p class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.friendRequestsDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.sharedInstanceInvitesTitle) }}
|
||||
</h2>
|
||||
<Chips
|
||||
v-model="sharedInstanceInviteSource"
|
||||
:items="sharedInstanceInviteSourceOptions"
|
||||
:format-label="formatInteractionSource"
|
||||
:disabled-items="sharedInstanceInviteSourceOptions"
|
||||
:disabled-tooltip="formatMessage(messages.comingSoon)"
|
||||
:capitalize="false"
|
||||
:aria-label="formatMessage(messages.sharedInstanceInvitesTitle)"
|
||||
/>
|
||||
<p class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.sharedInstanceInvitesDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.blockedUsersTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.blockedUsersDescription) }}
|
||||
</p>
|
||||
<ul class="m-0 flex list-disc flex-col gap-1 pl-5 text-secondary">
|
||||
<li>{{ formatMessage(messages.friendRequestsRestriction) }}</li>
|
||||
<li>{{ formatMessage(messages.sharedInstancesRestriction) }}</li>
|
||||
<li>{{ formatMessage(messages.hostingRestriction) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="relative overflow-hidden rounded-2xl border border-solid border-surface-4">
|
||||
<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-3"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-3"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-3 bg-gradient-to-b from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
ref="blockedUsersTable"
|
||||
class="max-h-[20.5rem] overflow-y-auto"
|
||||
@scroll="checkScrollState"
|
||||
>
|
||||
<Table
|
||||
class="!rounded-none !border-0"
|
||||
:columns="columns"
|
||||
:data="blockedUsers"
|
||||
row-key="id"
|
||||
>
|
||||
<template #empty-state>
|
||||
<div class="flex h-40 items-center justify-center px-4 text-center text-secondary">
|
||||
<div v-if="isLoading" class="flex items-center gap-2">
|
||||
<SpinnerIcon class="size-5 animate-spin" aria-hidden="true" />
|
||||
{{ formatMessage(messages.loadingBlockedUsers) }}
|
||||
</div>
|
||||
<div v-else-if="loadError" class="flex flex-col items-center gap-3">
|
||||
<span>{{ formatMessage(messages.loadError) }}</span>
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" @click="retry">
|
||||
{{ formatMessage(commonMessages.retryButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span v-else>{{ formatMessage(messages.noBlockedUsers) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-user="{ row }">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<Avatar
|
||||
:src="row.avatar_url"
|
||||
:alt="formatMessage(messages.userAvatarAlt, { username: row.username })"
|
||||
:tint-by="row.username"
|
||||
size="32px"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="truncate font-semibold text-contrast">
|
||||
{{ row.name ?? row.username }}
|
||||
</span>
|
||||
<span v-if="row.name" class="truncate text-sm text-secondary">
|
||||
{{ row.username }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="unblockingUserId !== null"
|
||||
:aria-label="
|
||||
formatMessage(messages.unblockUserAriaLabel, {
|
||||
username: row.username,
|
||||
})
|
||||
"
|
||||
@click="unblock(row)"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="unblockingUserId === row.id"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ formatMessage(messages.unblockButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<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-3"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-3"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-3 bg-gradient-to-t from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// TODO this will be moved in with the rest of the xplat settings.
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { LogInIcon, SpinnerIcon, ThinkingRinthbot } from '@modrinth/assets'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Chips from '#ui/components/base/Chips.vue'
|
||||
import EmptyState from '#ui/components/base/EmptyState.vue'
|
||||
import Table, { type TableColumn } from '#ui/components/base/Table.vue'
|
||||
import { defineMessages, useScrollIndicator, useVIntl } from '#ui/composables'
|
||||
import { injectAuth, injectNotificationManager } from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils'
|
||||
|
||||
import { blockedUsersQueryKey } from '../shared/user-profile/providers'
|
||||
|
||||
type BlockedUserTableColumn = 'user' | 'actions'
|
||||
type BlockedUser = Labrinth.Users.v2.User & Record<BlockedUserTableColumn, unknown>
|
||||
type FriendRequestSource = 'everyone' | 'mutuals' | 'no-one'
|
||||
type SharedInstanceInviteSource = 'everyone' | 'friends' | 'no-one'
|
||||
|
||||
const props = defineProps<{
|
||||
getBlockedUsers: () => Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]>
|
||||
getUsers: (userIds: string[]) => Promise<Labrinth.Users.v2.User[]>
|
||||
unblockUser: (userId: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
const auth = injectAuth()
|
||||
const notificationManager = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
const blockedUsersTable = ref<HTMLElement | null>(null)
|
||||
const unblockingUserId = ref<string | null>(null)
|
||||
const friendRequestSource = ref<FriendRequestSource>('everyone')
|
||||
const sharedInstanceInviteSource = ref<SharedInstanceInviteSource>('everyone')
|
||||
const friendRequestSourceOptions: FriendRequestSource[] = ['everyone', 'mutuals', 'no-one']
|
||||
const sharedInstanceInviteSourceOptions: SharedInstanceInviteSource[] = [
|
||||
'everyone',
|
||||
'friends',
|
||||
'no-one',
|
||||
]
|
||||
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(blockedUsersTable)
|
||||
|
||||
function formatInteractionSource(source: FriendRequestSource | SharedInstanceInviteSource): string {
|
||||
switch (source) {
|
||||
case 'everyone':
|
||||
return formatMessage(messages.everyone)
|
||||
case 'mutuals':
|
||||
return formatMessage(messages.friendsOfFriends)
|
||||
case 'friends':
|
||||
return formatMessage(messages.friends)
|
||||
case 'no-one':
|
||||
return formatMessage(messages.noOne)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed<TableColumn<BlockedUserTableColumn>[]>(() => [
|
||||
{
|
||||
key: 'user',
|
||||
label: formatMessage(messages.userColumn),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: formatMessage(messages.actionsColumn),
|
||||
align: 'right',
|
||||
width: '8rem',
|
||||
},
|
||||
])
|
||||
|
||||
const blockedUserIdsQuery = useQuery({
|
||||
queryKey: computed(() => blockedUsersQueryKey(auth.user.value?.id)),
|
||||
queryFn: props.getBlockedUsers,
|
||||
enabled: computed(() => Boolean(auth.user.value)),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const blockedUserIds = computed(() => blockedUserIdsQuery.data.value ?? [])
|
||||
const blockedUserProfilesQueryKey = computed(
|
||||
() => ['blocked-user-profiles', auth.user.value?.id ?? null, blockedUserIds.value] as const,
|
||||
)
|
||||
const blockedUserProfilesQuery = useQuery({
|
||||
queryKey: blockedUserProfilesQueryKey,
|
||||
queryFn: () => props.getUsers(blockedUserIds.value),
|
||||
enabled: computed(() => Boolean(auth.user.value && blockedUserIds.value.length)),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const blockedUsers = computed<BlockedUser[]>(() => {
|
||||
const profilesById = new Map(
|
||||
(blockedUserProfilesQuery.data.value ?? []).map((user) => [user.id, user]),
|
||||
)
|
||||
return blockedUserIds.value
|
||||
.map((userId) => profilesById.get(userId))
|
||||
.filter((user): user is Labrinth.Users.v2.User => Boolean(user))
|
||||
.map((user) => ({
|
||||
...user,
|
||||
user: user.username,
|
||||
actions: null,
|
||||
}))
|
||||
})
|
||||
const isLoading = computed(
|
||||
() =>
|
||||
Boolean(auth.user.value) &&
|
||||
(blockedUserIdsQuery.isPending.value ||
|
||||
(blockedUserIds.value.length > 0 && blockedUserProfilesQuery.isPending.value)),
|
||||
)
|
||||
const loadError = computed(
|
||||
() => blockedUserIdsQuery.error.value ?? blockedUserProfilesQuery.error.value,
|
||||
)
|
||||
|
||||
async function retry(): Promise<void> {
|
||||
await blockedUserIdsQuery.refetch()
|
||||
if (blockedUserIds.value.length > 0) {
|
||||
await blockedUserProfilesQuery.refetch()
|
||||
}
|
||||
}
|
||||
|
||||
async function requestSignIn(): Promise<void> {
|
||||
await auth.requestSignIn('')
|
||||
}
|
||||
|
||||
async function unblock(user: BlockedUser): Promise<void> {
|
||||
if (unblockingUserId.value) return
|
||||
|
||||
unblockingUserId.value = user.id
|
||||
try {
|
||||
await props.unblockUser(user.id)
|
||||
|
||||
const remainingIds = blockedUserIds.value.filter((userId) => userId !== user.id)
|
||||
const remainingUsers = blockedUsers.value.filter((blockedUser) => blockedUser.id !== user.id)
|
||||
queryClient.setQueryData(
|
||||
['blocked-user-profiles', auth.user.value?.id ?? null, remainingIds],
|
||||
remainingUsers,
|
||||
)
|
||||
queryClient.setQueryData<Labrinth.BlockedUsers.v3.BlockedUserId[]>(
|
||||
blockedUsersQueryKey(auth.user.value?.id),
|
||||
remainingIds,
|
||||
)
|
||||
} catch {
|
||||
notificationManager.addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.unblockError),
|
||||
text: formatMessage(messages.unblockErrorDescription),
|
||||
})
|
||||
} finally {
|
||||
unblockingUserId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
friendRequestsTitle: {
|
||||
id: 'settings.social.friend-requests.title',
|
||||
defaultMessage: 'Friend requests',
|
||||
},
|
||||
friendRequestsDescription: {
|
||||
id: 'settings.social.friend-requests.description',
|
||||
defaultMessage: 'Control who can send you friend requests on Modrinth.',
|
||||
},
|
||||
sharedInstanceInvitesTitle: {
|
||||
id: 'settings.social.shared-instance-invites.title',
|
||||
defaultMessage: 'Invitations',
|
||||
},
|
||||
sharedInstanceInvitesDescription: {
|
||||
id: 'settings.social.shared-instance-invites.description',
|
||||
defaultMessage:
|
||||
'Control who can send you invites to shared instances and Modrinth Hosting panels.',
|
||||
},
|
||||
everyone: {
|
||||
id: 'settings.social.interaction-source.everyone',
|
||||
defaultMessage: 'Everyone',
|
||||
},
|
||||
friendsOfFriends: {
|
||||
id: 'settings.social.interaction-source.friends-of-friends',
|
||||
defaultMessage: 'Friends of friends',
|
||||
},
|
||||
friends: {
|
||||
id: 'settings.social.interaction-source.friends',
|
||||
defaultMessage: 'Friends',
|
||||
},
|
||||
noOne: {
|
||||
id: 'settings.social.interaction-source.no-one',
|
||||
defaultMessage: 'No one',
|
||||
},
|
||||
comingSoon: {
|
||||
id: 'settings.social.interaction-source.coming-soon',
|
||||
defaultMessage: 'Coming soon!',
|
||||
},
|
||||
blockedUsersTitle: {
|
||||
id: 'settings.social.blocked-users.title',
|
||||
defaultMessage: 'Blocked users',
|
||||
},
|
||||
blockedUsersDescription: {
|
||||
id: 'settings.social.blocked-users.description',
|
||||
defaultMessage: 'These are the users you have blocked on Modrinth. They cannot:',
|
||||
},
|
||||
friendRequestsRestriction: {
|
||||
id: 'settings.social.blocked-users.restriction.friend-requests',
|
||||
defaultMessage: 'Send you friend requests',
|
||||
},
|
||||
sharedInstancesRestriction: {
|
||||
id: 'settings.social.blocked-users.restriction.shared-instances',
|
||||
defaultMessage: 'Invite you to shared instances',
|
||||
},
|
||||
hostingRestriction: {
|
||||
id: 'settings.social.blocked-users.restriction.hosting',
|
||||
defaultMessage: 'Invite you to manage a Modrinth Hosting server.',
|
||||
},
|
||||
userColumn: {
|
||||
id: 'settings.social.blocked-users.column.user',
|
||||
defaultMessage: 'User',
|
||||
},
|
||||
actionsColumn: {
|
||||
id: 'settings.social.blocked-users.column.actions',
|
||||
defaultMessage: 'Actions',
|
||||
},
|
||||
unblockButton: {
|
||||
id: 'settings.social.blocked-users.unblock',
|
||||
defaultMessage: 'Unblock',
|
||||
},
|
||||
unblockUserAriaLabel: {
|
||||
id: 'settings.social.blocked-users.unblock-user',
|
||||
defaultMessage: 'Unblock {username}',
|
||||
},
|
||||
loadingBlockedUsers: {
|
||||
id: 'settings.social.blocked-users.loading',
|
||||
defaultMessage: 'Loading blocked users…',
|
||||
},
|
||||
noBlockedUsers: {
|
||||
id: 'settings.social.blocked-users.empty',
|
||||
defaultMessage: "You haven't blocked anyone.",
|
||||
},
|
||||
signInRequiredTitle: {
|
||||
id: 'settings.social.sign-in-required.title',
|
||||
defaultMessage: 'Modrinth account required',
|
||||
},
|
||||
signInRequiredDescription: {
|
||||
id: 'settings.social.sign-in-required.description',
|
||||
defaultMessage:
|
||||
'You can control who can interact with you, and manage blocked users with a Modrinth Account',
|
||||
},
|
||||
loadError: {
|
||||
id: 'settings.social.blocked-users.load-error',
|
||||
defaultMessage: 'Blocked users could not be loaded.',
|
||||
},
|
||||
userAvatarAlt: {
|
||||
id: 'settings.social.blocked-users.user-avatar',
|
||||
defaultMessage: "{username}'s avatar",
|
||||
},
|
||||
unblockError: {
|
||||
id: 'settings.social.blocked-users.unblock-error',
|
||||
defaultMessage: 'Failed to unblock user',
|
||||
},
|
||||
unblockErrorDescription: {
|
||||
id: 'settings.social.blocked-users.unblock-error-description',
|
||||
defaultMessage: 'An error occurred while unblocking this user. Please try again.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,3 +1,5 @@
|
||||
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'
|
||||
export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue'
|
||||
|
||||
Reference in New Issue
Block a user