mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 13:16:38 +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:
@@ -146,6 +146,7 @@ import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
||||
import { get_available_capes, get_available_skins } from './helpers/skins'
|
||||
import { AppNotificationManager } from './providers/app-notifications'
|
||||
import { AppPopupNotificationManager } from './providers/app-popup-notifications'
|
||||
import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
|
||||
|
||||
const themeStore = useTheming()
|
||||
const router = useRouter()
|
||||
@@ -795,6 +796,8 @@ const sharedInstanceInviteHandler = ref()
|
||||
const updateToPlayModal = ref()
|
||||
|
||||
const modrinthLoginModal = ref()
|
||||
const appSettingsModal = ref()
|
||||
provide(appSettingsModalOpenProfileKey, () => appSettingsModal.value?.showProfile())
|
||||
|
||||
watch(incompatibilityWarningModal, (modal) => {
|
||||
if (modal) {
|
||||
@@ -1500,7 +1503,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
</div>
|
||||
</Transition>
|
||||
<Suspense>
|
||||
<AppSettingsModal ref="settingsModal" />
|
||||
<AppSettingsModal ref="appSettingsModal" />
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<ModrinthAccountRequiredModal ref="modrinthLoginModal" :request-auth="requestModrinthAuth" />
|
||||
@@ -1568,7 +1571,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<div class="flex flex-grow"></div>
|
||||
<NavButton
|
||||
v-tooltip.right="formatMessage(commonMessages.settingsLabel)"
|
||||
:to="() => $refs.settingsModal.show()"
|
||||
:to="() => appSettingsModal?.show()"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</NavButton>
|
||||
|
||||
@@ -123,45 +123,46 @@ const messages = defineMessages({
|
||||
<div
|
||||
v-for="friend in friends"
|
||||
:key="friend.username"
|
||||
class="group grid items-center grid-cols-[auto_1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
|
||||
class="group grid items-center grid-cols-[1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
|
||||
@contextmenu.prevent.stop="
|
||||
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
|
||||
"
|
||||
>
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:src="friend.avatar"
|
||||
:class="{ grayscale: !friend.online && friend.accepted }"
|
||||
class="w-12 h-12 rounded-full"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<span
|
||||
v-if="friend.online"
|
||||
aria-hidden="true"
|
||||
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-sm m-0"
|
||||
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ friend.username }}
|
||||
</span>
|
||||
<span v-if="!friend.accepted" class="m-0 text-xs">
|
||||
{{ formatMessage(messages.friendRequestSent) }}
|
||||
</span>
|
||||
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
|
||||
</div>
|
||||
<RouterLink
|
||||
:to="`/user/${encodeURIComponent(friend.username)}`"
|
||||
class="grid min-w-0 grid-cols-[auto_1fr] items-center gap-2 text-inherit no-underline"
|
||||
>
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:src="friend.avatar"
|
||||
:class="{ grayscale: !friend.online && friend.accepted }"
|
||||
class="w-12 h-12 rounded-full"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<span
|
||||
v-if="friend.online"
|
||||
aria-hidden="true"
|
||||
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-sm m-0"
|
||||
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ friend.username }}
|
||||
</span>
|
||||
<span v-if="!friend.accepted" class="m-0 text-xs">
|
||||
{{ formatMessage(messages.friendRequestSent) }}
|
||||
</span>
|
||||
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
<ButtonStyled v-if="friend.accepted" circular type="transparent">
|
||||
<OverflowMenu
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:options="[
|
||||
{
|
||||
id: 'view-profile',
|
||||
action: () => openProfile(friend.username),
|
||||
},
|
||||
{
|
||||
id: 'remove-friend',
|
||||
action: () => removeFriend(friend),
|
||||
@@ -170,10 +171,6 @@ const messages = defineMessages({
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #view-profile>
|
||||
<UserIcon />
|
||||
{{ formatMessage(messages.viewProfile) }}
|
||||
</template>
|
||||
<template #remove-friend>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.removeFriend) }}
|
||||
|
||||
@@ -3,12 +3,14 @@ import {
|
||||
CoffeeIcon,
|
||||
GameIcon,
|
||||
GaugeIcon,
|
||||
HeartHandshakeIcon,
|
||||
LanguagesIcon,
|
||||
ModrinthIcon,
|
||||
PaintbrushIcon,
|
||||
Settings2Icon,
|
||||
ShieldIcon,
|
||||
ToggleRightIcon,
|
||||
UserIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
commonMessages,
|
||||
@@ -17,13 +19,16 @@ import {
|
||||
defineMessages,
|
||||
ProgressBar,
|
||||
TabbedModal,
|
||||
UnsavedChangesPopup,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, provide, ref, watch } from 'vue'
|
||||
|
||||
import PrivacySettings from '@/components/ui/settings/account/PrivacySettings.vue'
|
||||
import ProfileSettings from '@/components/ui/settings/account/ProfileSettings.vue'
|
||||
import SocialSettings from '@/components/ui/settings/account/SocialSettings.vue'
|
||||
import AppearanceSettings from '@/components/ui/settings/display/AppearanceSettings.vue'
|
||||
import BehaviorSettings from '@/components/ui/settings/display/BehaviorSettings.vue'
|
||||
import FeatureFlagSettings from '@/components/ui/settings/display/FeatureFlagSettings.vue'
|
||||
@@ -32,6 +37,10 @@ import DefaultInstanceSettings from '@/components/ui/settings/instances/DefaultI
|
||||
import JavaSettings from '@/components/ui/settings/instances/JavaSettings.vue'
|
||||
import ResourceManagementSettings from '@/components/ui/settings/instances/ResourceManagementSettings.vue'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import {
|
||||
appSettingsModalContextKey,
|
||||
type UnsavedChangesController,
|
||||
} from '@/providers/app-settings-modal'
|
||||
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress.ts'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
@@ -98,6 +107,18 @@ const tabs = [
|
||||
content: FeatureFlagSettings,
|
||||
developerOnly: true,
|
||||
},
|
||||
{
|
||||
name: commonSettingsMessages.profile,
|
||||
category: tabCategories.account,
|
||||
icon: UserIcon,
|
||||
content: ProfileSettings,
|
||||
},
|
||||
{
|
||||
name: commonSettingsMessages.social,
|
||||
category: tabCategories.account,
|
||||
icon: HeartHandshakeIcon,
|
||||
content: SocialSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'app.settings.tabs.privacy',
|
||||
@@ -110,7 +131,7 @@ const tabs = [
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'app.settings.tabs.default-instance-options',
|
||||
defaultMessage: 'Default instance options',
|
||||
defaultMessage: 'Default game options',
|
||||
}),
|
||||
category: tabCategories.instances,
|
||||
icon: GameIcon,
|
||||
@@ -139,12 +160,58 @@ const tabs = [
|
||||
const availableTabs = computed(() => tabs.filter((tab) => !tab.developerOnly || themeStore.devMode))
|
||||
|
||||
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
const unsavedChangesPopup = ref<{ nudge: () => void } | null>(null)
|
||||
const unsavedChangesController = ref<UnsavedChangesController | null>(null)
|
||||
const emptyUnsavedChangesState: Record<string, unknown> = {}
|
||||
const originalUnsavedChangesState = computed(
|
||||
() => unsavedChangesController.value?.getOriginal() ?? emptyUnsavedChangesState,
|
||||
)
|
||||
const modifiedUnsavedChangesState = computed(
|
||||
() => unsavedChangesController.value?.getModified() ?? emptyUnsavedChangesState,
|
||||
)
|
||||
const savingUnsavedChanges = computed(() => unsavedChangesController.value?.isSaving() ?? false)
|
||||
const hasUnsavedChanges = computed(() => unsavedChangesController.value?.hasChanges() ?? false)
|
||||
|
||||
function canLeaveCurrentTab(): boolean {
|
||||
if (!unsavedChangesController.value?.hasChanges()) return true
|
||||
unsavedChangesPopup.value?.nudge()
|
||||
return false
|
||||
}
|
||||
|
||||
function close(): boolean {
|
||||
return modal.value?.hide() ?? false
|
||||
}
|
||||
|
||||
function registerUnsavedChangesController(controller: UnsavedChangesController | null): void {
|
||||
unsavedChangesController.value = controller
|
||||
}
|
||||
|
||||
provide(appSettingsModalContextKey, {
|
||||
close,
|
||||
registerUnsavedChangesController,
|
||||
})
|
||||
|
||||
function resetUnsavedChanges(): void {
|
||||
unsavedChangesController.value?.reset()
|
||||
}
|
||||
|
||||
function saveUnsavedChanges(): void {
|
||||
void unsavedChangesController.value?.save()
|
||||
}
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
function showProfile(): void {
|
||||
const profileTabIndex = availableTabs.value.findIndex((tab) => tab.content === ProfileSettings)
|
||||
if (profileTabIndex >= 0) {
|
||||
modal.value?.setTab(profileTabIndex)
|
||||
}
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
defineExpose({ show, showProfile })
|
||||
|
||||
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
|
||||
|
||||
@@ -197,12 +264,30 @@ const messages = defineMessages({
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<TabbedModal ref="modal" :tabs="availableTabs" :width="'min(928px, calc(95vw - 10rem))'">
|
||||
<TabbedModal
|
||||
ref="modal"
|
||||
:tabs="availableTabs"
|
||||
:width="'min(928px, calc(95vw - 10rem))'"
|
||||
:before-hide="canLeaveCurrentTab"
|
||||
:before-tab-change="canLeaveCurrentTab"
|
||||
:floating-action-bar-shown="hasUnsavedChanges"
|
||||
>
|
||||
<template #title>
|
||||
<span class="text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(commonMessages.settingsLabel) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #floating-action-bar>
|
||||
<UnsavedChangesPopup
|
||||
ref="unsavedChangesPopup"
|
||||
:original="originalUnsavedChangesState"
|
||||
:modified="modifiedUnsavedChangesState"
|
||||
:saving="savingUnsavedChanges"
|
||||
inline
|
||||
@reset="resetUnsavedChanges"
|
||||
@save="saveUnsavedChanges"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="mt-auto text-secondary text-sm">
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<AccountProfileSettings
|
||||
ref="profileSettings"
|
||||
:patch-user="patchUser"
|
||||
:change-avatar="changeAvatar"
|
||||
:delete-avatar="deleteAvatar"
|
||||
:get-authenticated-user="getAuthenticatedUser"
|
||||
@profile-link-click="handleProfileLinkClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { AccountProfileSettings, injectAuth } from '@modrinth/ui'
|
||||
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
change_user_avatar,
|
||||
delete_user_avatar,
|
||||
get_user_profile,
|
||||
patch_user,
|
||||
} from '@/helpers/users'
|
||||
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
|
||||
|
||||
const settingsModal = inject(appSettingsModalContextKey, null)
|
||||
const auth = injectAuth()
|
||||
const profileSettings = ref<InstanceType<typeof AccountProfileSettings> | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
settingsModal?.registerUnsavedChangesController({
|
||||
hasChanges: () => profileSettings.value?.hasChanges ?? false,
|
||||
getOriginal: () => profileSettings.value?.originalState ?? {},
|
||||
getModified: () => profileSettings.value?.modifiedState ?? {},
|
||||
isSaving: () => profileSettings.value?.saving ?? false,
|
||||
reset: () => profileSettings.value?.reset(),
|
||||
save: () => profileSettings.value?.save(),
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
settingsModal?.registerUnsavedChangesController(null)
|
||||
})
|
||||
|
||||
function handleProfileLinkClick(event: MouseEvent): void {
|
||||
if (settingsModal && !settingsModal.close()) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function patchUser(
|
||||
userId: string,
|
||||
patch: Partial<Pick<Labrinth.Users.v2.User, 'bio' | 'username'>>,
|
||||
): Promise<void> {
|
||||
return patch_user(userId, patch)
|
||||
}
|
||||
|
||||
async function changeAvatar(userId: string, file: Blob, extension: string): Promise<void> {
|
||||
await change_user_avatar(userId, new Uint8Array(await file.arrayBuffer()), extension)
|
||||
}
|
||||
|
||||
function deleteAvatar(userId: string): Promise<void> {
|
||||
return delete_user_avatar(userId)
|
||||
}
|
||||
|
||||
function getAuthenticatedUser(): Promise<Labrinth.Users.v3.User> {
|
||||
const userId = auth.user.value?.id
|
||||
if (!userId) throw new Error('Cannot refresh a signed-out user.')
|
||||
return get_user_profile(userId)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<AccountSocialSettings
|
||||
:get-blocked-users="get_blocked_users"
|
||||
:get-users="getUsers"
|
||||
:unblock-user="unblock_user"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { AccountSocialSettings, injectModrinthClient } from '@modrinth/ui'
|
||||
|
||||
import { get_blocked_users, unblock_user } from '@/helpers/users'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
|
||||
function getUsers(userIds: string[]): Promise<Labrinth.Users.v2.User[]> {
|
||||
return client.labrinth.users_v2.getMultiple(userIds)
|
||||
}
|
||||
</script>
|
||||
@@ -25,7 +25,7 @@ watch(
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5 min-w-[600px]">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div v-for="option in options" :key="option" class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast capitalize">
|
||||
|
||||
+70
-10
@@ -120,8 +120,17 @@
|
||||
:max-height="240"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="reportOnly" class="flex flex-col gap-2">
|
||||
<Checkbox v-model="deleteInstance" :label="formatMessage(messages.deleteInstance)" />
|
||||
<div v-if="reportOnly || blockTargetUserId" class="flex flex-col gap-2">
|
||||
<Checkbox
|
||||
v-if="reportOnly"
|
||||
v-model="deleteInstance"
|
||||
:label="formatMessage(messages.deleteInstance)"
|
||||
/>
|
||||
<Checkbox
|
||||
v-if="blockTargetUserId"
|
||||
v-model="blockUser"
|
||||
:label="formatMessage(messages.blockUser)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -249,12 +258,14 @@ import {
|
||||
Admonition,
|
||||
AutoLink,
|
||||
Avatar,
|
||||
blockedUsersQueryKey,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectAuth,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
@@ -266,6 +277,7 @@ import {
|
||||
useScrollIndicator,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
@@ -274,6 +286,7 @@ import { hide_ads_window, show_ads_window } from '@/helpers/ads'
|
||||
import { toError } from '@/helpers/errors'
|
||||
import type { SharedInstanceInstallPreview } from '@/helpers/install'
|
||||
import { create_report } from '@/helpers/reports'
|
||||
import { block_user } from '@/helpers/users'
|
||||
|
||||
import SharedInstanceInstallSummary from './shared-instance-install-summary.vue'
|
||||
import { useSharedInstancePreviewContent } from './use-shared-instance-preview-content'
|
||||
@@ -284,6 +297,7 @@ type ExternalFileRow = {
|
||||
name: string
|
||||
}
|
||||
type SharedInstanceCreator = {
|
||||
id: string | null
|
||||
username: string
|
||||
avatarUrl: string | null
|
||||
}
|
||||
@@ -300,13 +314,17 @@ type ReportReason = 'malicious' | 'inappropriate' | 'spam'
|
||||
const reportReason = ref<ReportReason>('malicious')
|
||||
const additionalContext = ref('')
|
||||
const deleteInstance = ref(true)
|
||||
const blockUser = ref(true)
|
||||
const blockTargetUserId = ref<string | null>(null)
|
||||
const submitLoading = ref(false)
|
||||
const uploadedImageIDs = ref<string[]>([])
|
||||
const emit = defineEmits<{
|
||||
reported: [deleteInstance: boolean]
|
||||
}>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const auth = injectAuth()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const { load } = useSharedInstancePreviewContent()
|
||||
const {
|
||||
@@ -365,13 +383,39 @@ async function submitReport() {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const uploadedImages = uploadedImageIDs.value.slice(-10)
|
||||
await create_report({
|
||||
report_type: reportReason.value,
|
||||
item_type: 'shared-instance',
|
||||
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
|
||||
body,
|
||||
uploaded_images: uploadedImages,
|
||||
})
|
||||
const blockTarget = blockUser.value ? blockTargetUserId.value : null
|
||||
const [reportResult, blockResult] = await Promise.allSettled([
|
||||
create_report({
|
||||
report_type: reportReason.value,
|
||||
item_type: 'shared-instance',
|
||||
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
|
||||
body,
|
||||
uploaded_images: uploadedImages,
|
||||
}),
|
||||
blockTarget ? block_user(blockTarget) : Promise.resolve(),
|
||||
])
|
||||
|
||||
if (blockTarget) {
|
||||
if (blockResult.status === 'fulfilled') {
|
||||
blockUser.value = false
|
||||
const authUserId = auth.user.value?.id
|
||||
if (authUserId) {
|
||||
queryClient.setQueryData<Labrinth.BlockedUsers.v3.BlockedUserId[]>(
|
||||
blockedUsersQueryKey(authUserId),
|
||||
(blockedUsers = []) =>
|
||||
blockedUsers.includes(blockTarget) ? blockedUsers : [...blockedUsers, blockTarget],
|
||||
)
|
||||
}
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.userBlocked),
|
||||
})
|
||||
} else {
|
||||
handleError(toError(blockResult.reason))
|
||||
}
|
||||
}
|
||||
|
||||
if (reportResult.status === 'rejected') throw reportResult.reason
|
||||
|
||||
const shouldDeleteInstance = reportOnly.value && deleteInstance.value
|
||||
hide()
|
||||
@@ -426,6 +470,8 @@ function resetReportState() {
|
||||
reportReason.value = 'malicious'
|
||||
additionalContext.value = ''
|
||||
deleteInstance.value = true
|
||||
blockUser.value = true
|
||||
blockTargetUserId.value = null
|
||||
submitLoading.value = false
|
||||
uploadedImageIDs.value = []
|
||||
}
|
||||
@@ -437,12 +483,18 @@ function show(
|
||||
) {
|
||||
resetReportState()
|
||||
creator.value = creatorValue ?? null
|
||||
blockTargetUserId.value = creatorValue?.id ?? null
|
||||
install.value = installValue
|
||||
showPreview(previewValue, event)
|
||||
}
|
||||
function showReport(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
|
||||
function showReport(
|
||||
previewValue: SharedInstanceInstallPreview,
|
||||
blockTargetUserIdValue?: string | null,
|
||||
event?: MouseEvent,
|
||||
) {
|
||||
resetReportState()
|
||||
creator.value = null
|
||||
blockTargetUserId.value = blockTargetUserIdValue ?? null
|
||||
reportMode.value = true
|
||||
reportOnly.value = true
|
||||
install.value = () => {}
|
||||
@@ -537,6 +589,14 @@ const messages = defineMessages({
|
||||
id: 'app.modal.install-to-play.delete-instance',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
blockUser: {
|
||||
id: 'app.modal.install-to-play.block-user',
|
||||
defaultMessage: 'Block user',
|
||||
},
|
||||
userBlocked: {
|
||||
id: 'app.modal.install-to-play.user-blocked',
|
||||
defaultMessage: 'User blocked',
|
||||
},
|
||||
unknownFilesWarning: {
|
||||
id: 'app.modal.install-to-play.unknown-files-warning',
|
||||
defaultMessage: 'Unknown files warning',
|
||||
|
||||
+3
@@ -32,6 +32,7 @@ type InstallModal = {
|
||||
}
|
||||
|
||||
type SharedInstanceCreator = {
|
||||
id: string | null
|
||||
username: string
|
||||
avatarUrl: string | null
|
||||
}
|
||||
@@ -190,6 +191,7 @@ export function useSharedInstanceInviteHandler(
|
||||
},
|
||||
invite.invitedByUsername
|
||||
? {
|
||||
id: invite.invitedById,
|
||||
username: invite.invitedByUsername,
|
||||
avatarUrl: invite.invitedByAvatarUrl,
|
||||
}
|
||||
@@ -294,6 +296,7 @@ export function useSharedInstanceInviteHandler(
|
||||
},
|
||||
manager
|
||||
? {
|
||||
id: manager.id,
|
||||
username: manager.username,
|
||||
avatarUrl: manager.avatar_url ?? null,
|
||||
}
|
||||
|
||||
@@ -50,7 +50,31 @@ export async function get_user_collections(
|
||||
|
||||
export async function patch_user(
|
||||
userId: string,
|
||||
patch: Partial<Pick<Labrinth.Users.v3.User, 'badges' | 'role'>>,
|
||||
patch: Partial<Pick<Labrinth.Users.v2.User, 'badges' | 'bio' | 'role' | 'username'>>,
|
||||
): Promise<void> {
|
||||
await invoke('plugin:users|patch_user', { userId, patch })
|
||||
}
|
||||
|
||||
export async function change_user_avatar(
|
||||
userId: string,
|
||||
image: Uint8Array,
|
||||
extension: string,
|
||||
): Promise<void> {
|
||||
await invoke('plugin:users|change_user_avatar', { userId, image, extension })
|
||||
}
|
||||
|
||||
export async function delete_user_avatar(userId: string): Promise<void> {
|
||||
await invoke('plugin:users|delete_user_avatar', { userId })
|
||||
}
|
||||
|
||||
export async function block_user(userId: string): Promise<void> {
|
||||
await invoke('plugin:users|block_user', { userId })
|
||||
}
|
||||
|
||||
export async function unblock_user(userId: string): Promise<void> {
|
||||
await invoke('plugin:users|unblock_user', { userId })
|
||||
}
|
||||
|
||||
export async function get_blocked_users(): Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]> {
|
||||
return await invoke<Labrinth.BlockedUsers.v3.BlockedUserId[]>('plugin:users|get_blocked_users')
|
||||
}
|
||||
|
||||
@@ -650,6 +650,9 @@
|
||||
"app.modal.install-to-play.additional-context-placeholder": {
|
||||
"message": "Include links and images if possible and relevant"
|
||||
},
|
||||
"app.modal.install-to-play.block-user": {
|
||||
"message": "Block user"
|
||||
},
|
||||
"app.modal.install-to-play.content-you-are-reporting": {
|
||||
"message": "Instance you’re reporting"
|
||||
},
|
||||
@@ -731,6 +734,9 @@
|
||||
"app.modal.install-to-play.unrecognized-files": {
|
||||
"message": "Unrecognized files"
|
||||
},
|
||||
"app.modal.install-to-play.user-blocked": {
|
||||
"message": "User blocked"
|
||||
},
|
||||
"app.modal.install-to-play.view-contents": {
|
||||
"message": "View contents"
|
||||
},
|
||||
@@ -990,7 +996,7 @@
|
||||
"message": "Behavior"
|
||||
},
|
||||
"app.settings.tabs.default-instance-options": {
|
||||
"message": "Default instance options"
|
||||
"message": "Default game options"
|
||||
},
|
||||
"app.settings.tabs.java-installations": {
|
||||
"message": "Java installations"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<div class="w-full pt-2">
|
||||
<div class="w-full px-2 pt-2">
|
||||
<UserProfilePageLayout
|
||||
:user-id="userId"
|
||||
:project-type="projectType"
|
||||
variant="app"
|
||||
site-url="https://modrinth.com"
|
||||
project-link-mode="app"
|
||||
:edit-profile-link="openProfileSettings"
|
||||
external-navigation
|
||||
/>
|
||||
</div>
|
||||
@@ -14,19 +15,24 @@
|
||||
<script setup lang="ts">
|
||||
import { provideUserProfile, UserProfilePageLayout } from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, inject, watch } from 'vue'
|
||||
import { onBeforeRouteUpdate, useRoute } from 'vue-router'
|
||||
|
||||
import {
|
||||
block_user,
|
||||
get_blocked_users,
|
||||
get_user_collections,
|
||||
get_user_organizations,
|
||||
get_user_profile,
|
||||
get_user_projects,
|
||||
patch_user,
|
||||
unblock_user,
|
||||
} from '@/helpers/users'
|
||||
import { appSettingsModalOpenProfileKey } from '@/providers/app-settings-modal'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
|
||||
const route = useRoute()
|
||||
const openProfileSettings = inject(appSettingsModalOpenProfileKey, () => {})
|
||||
const queryClient = useQueryClient()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const userProfile = provideUserProfile({
|
||||
@@ -35,6 +41,9 @@ const userProfile = provideUserProfile({
|
||||
getOrganizations: get_user_organizations,
|
||||
getCollections: get_user_collections,
|
||||
patchUser: patch_user,
|
||||
getBlockedUsers: get_blocked_users,
|
||||
blockUser: block_user,
|
||||
unblockUser: unblock_user,
|
||||
})
|
||||
|
||||
const userId = computed(() => {
|
||||
|
||||
@@ -701,7 +701,7 @@ async function reportSharedInstance(event?: MouseEvent, closeUpdateModal = false
|
||||
)
|
||||
if (instance.value?.id !== reportInstance.id) return
|
||||
if (closeUpdateModal) sharedInstanceUpdateModal.value?.hide()
|
||||
sharedInstanceReportModal.value?.showReport(preview, event)
|
||||
sharedInstanceReportModal.value?.showReport(preview, sharedInstance.manager_id, event)
|
||||
} catch (error) {
|
||||
notifySharedInstanceError(error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
export type UnsavedChangesController = {
|
||||
hasChanges: () => boolean
|
||||
getOriginal: () => Record<string, unknown>
|
||||
getModified: () => Record<string, unknown>
|
||||
isSaving: () => boolean
|
||||
reset: () => void
|
||||
save: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export type AppSettingsModalContext = {
|
||||
close: () => boolean
|
||||
registerUnsavedChangesController: (controller: UnsavedChangesController | null) => void
|
||||
}
|
||||
|
||||
export const appSettingsModalContextKey: InjectionKey<AppSettingsModalContext> =
|
||||
Symbol('appSettingsModalContext')
|
||||
export const appSettingsModalOpenProfileKey: InjectionKey<() => void> = Symbol(
|
||||
'appSettingsModalOpenProfile',
|
||||
)
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type AuthUser,
|
||||
provideAuth,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, type Ref, ref, watchEffect } from 'vue'
|
||||
import { computed, type Ref, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
type AppCredentials = {
|
||||
session?: string | null
|
||||
@@ -37,5 +37,13 @@ export function setupAuthProvider(
|
||||
user.value = credentials.value?.user ?? null
|
||||
})
|
||||
|
||||
watch(user, (updatedUser) => {
|
||||
if (!credentials.value || !updatedUser || credentials.value.user === updatedUser) return
|
||||
credentials.value = {
|
||||
...credentials.value,
|
||||
user: updatedUser,
|
||||
}
|
||||
})
|
||||
|
||||
provideAuth(authProvider)
|
||||
}
|
||||
|
||||
@@ -285,6 +285,9 @@ fn main() {
|
||||
"get_user_organizations",
|
||||
"get_user_collections",
|
||||
"patch_user",
|
||||
"block_user",
|
||||
"unblock_user",
|
||||
"get_blocked_users",
|
||||
])
|
||||
.default_permission(
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
|
||||
@@ -32,6 +32,38 @@ pub async fn patch_user(user_id: &str, patch: Value) -> Result<()> {
|
||||
Ok(theseus::users::patch_user(user_id, patch).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn change_user_avatar(
|
||||
user_id: &str,
|
||||
image: Vec<u8>,
|
||||
extension: &str,
|
||||
) -> Result<()> {
|
||||
Ok(
|
||||
theseus::users::change_user_avatar(user_id, image.into(), extension)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_user_avatar(user_id: &str) -> Result<()> {
|
||||
Ok(theseus::users::delete_user_avatar(user_id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn block_user(user_id: &str) -> Result<()> {
|
||||
Ok(theseus::users::block_user(user_id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn unblock_user(user_id: &str) -> Result<()> {
|
||||
Ok(theseus::users::unblock_user(user_id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_blocked_users() -> Result<Vec<String>> {
|
||||
Ok(theseus::users::get_blocked_users().await?)
|
||||
}
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("users")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -41,6 +73,11 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
get_user_organizations,
|
||||
get_user_collections,
|
||||
patch_user,
|
||||
change_user_avatar,
|
||||
delete_user_avatar,
|
||||
block_user,
|
||||
unblock_user,
|
||||
get_blocked_users,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -2972,12 +2972,6 @@
|
||||
"settings.pats.modal.delete.description": {
|
||||
"message": "Toto vymaže tento token navždy (jako opravdu navždy)."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Bio"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Profilový obrázek"
|
||||
},
|
||||
"settings.sidebar.label.developer": {
|
||||
"message": "Vývojář"
|
||||
},
|
||||
|
||||
@@ -3200,27 +3200,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Aldrig brugt"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "En kort beskrivelse, der fortæller alle lidt om dig selv."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Om dig"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Dine profiloplysninger kan ses offentligt på Modrinth og via <docs-link>Modrinth API</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Profil indstillinger"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Profiloplysninger"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Profilbillede"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Et unikt, ikke-versalfølsomt navn til at identificere din profil."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Inddrag adgang"
|
||||
},
|
||||
|
||||
@@ -5129,27 +5129,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Nie verwendet"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Eine kurze Beschreibung um allen etwas über dich zu erzählen."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Bio"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Deine Profilinformationen sind auf Modrinth und über die <docs-link>Modrinth API</docs-link> öffentlich einsehbar."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Profileinstellungen"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Profilinformationen"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Profilbild"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Ein eindeutiger Name (ohne berücksichtigung der Gross-/Kleinschreibung) zur identifikation deines Profils."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Sitzung widerrufen"
|
||||
},
|
||||
|
||||
@@ -5129,27 +5129,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Nie verwendet"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Eine kurze Beschreibung, um allen ein wenig über dich zu erzählen."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Bio"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Deine Profilinformationen sind auf Modrinth und über die <docs-link>Modrinth-API</docs-link> öffentlich einsehbar."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Profileinstellungen"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Profilinformationen"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Profilbild"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Ein eindeutiger Name ohne Berücksichtigung der Groß-/Kleinschreibung zur Identifizierung deines Profils."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Sitzung widerrufen"
|
||||
},
|
||||
|
||||
@@ -5174,27 +5174,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Never used"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "A short description to tell everyone a little bit about you."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Bio"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Your profile information is publicly viewable on Modrinth and through the <docs-link>Modrinth API</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Profile settings"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Profile information"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Profile picture"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "A unique case-insensitive name to identify your profile."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Revoke session"
|
||||
},
|
||||
|
||||
@@ -4637,27 +4637,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Nunca usado"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Una breve descripción para que todos sepan un poco sobre ti."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Biografía"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "La información de tu perfil es visible públicamente en Modrinth y mediante la <docs-link>API de Modrinth</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Configuración del perfil"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Información del perfil"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Imagen de perfil"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Un nombre único, sin distinción entre mayúsculas y minúsculas, para identificar tu perfil."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Revocar sesión"
|
||||
},
|
||||
|
||||
@@ -4310,27 +4310,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Nunca utilizado"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Una breve descripción para que todos sepan un poco sobre ti."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Biografía"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "La información de tu perfil es visible públicamente en Modrinth y a través de la <docs-link>API de Modrinth</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Configuración del perfil"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Información perfil"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Foto de perfil"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Un nombre único que no distingue entre mayúsculas y minúsculas para identificar tu perfil."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Revocar sesión"
|
||||
},
|
||||
|
||||
@@ -1055,9 +1055,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Ei koskaan käytetty"
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Kuvaus"
|
||||
},
|
||||
"settings.sessions.unknown-os": {
|
||||
"message": "Tuntematon käyttöjärjestelmä"
|
||||
}
|
||||
|
||||
@@ -3167,27 +3167,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Hindi ginamit"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Isang maikling paglalarawan upang maipaalam sa lahat ang maliit na sipi ukol sa iyo."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Biyograpiya"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Makikita ng publiko ang impormasyon ng iyong profile at sa <docs-link>API ng Modrinth</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Settings ng profile"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Impormasyon ng profile"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Profile picture"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Isang natatanging case-insensitive na pangalan upang matukoy ang iyong profile."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Bawiin ang sesyon"
|
||||
},
|
||||
|
||||
@@ -5117,27 +5117,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Jamais utilisé"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Une courte description pour indiquer à tout le monde un peu à propos de vous."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "À propos"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Votre information de profil est visible publiquement sur Modrinth et sur la <docs-link>Modrinth API</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Paramètres du profil"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Information du profil"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Photo de profil"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Un nom unique insensible à la casse pour identifier votre profil."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Révoquer la session"
|
||||
},
|
||||
|
||||
@@ -2305,11 +2305,5 @@
|
||||
},
|
||||
"settings.pats.modal.create.name.label": {
|
||||
"message": "שם"
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "תיאור"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "תמונת פרופיל"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4571,27 +4571,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Sohasem volt használva"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Egy rövid leírás, hogy mindenki megismerjen téged."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Bemutatkozás"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "A profiladataid nyilvánosan megtekinthetők a Modrinthon és a <docs-link>Modrinth API</docs-link>-n keresztül."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Profilbeállítások"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Profilinformációk"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Profilkép"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Egyedi, kis- és nagybetűktől független név a profilod azonosításához."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Munkamenet visszavonása"
|
||||
},
|
||||
|
||||
@@ -3185,24 +3185,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Tidak pernah digunakan"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Keterangan singkat untuk mengenalkan sedikit tentang diri Anda."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Bio"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Informasi profil Anda dapat dilihat secara publik di Modrinth dan melalui <docs-link>Modrinth API</docs-link>."
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Informasi profil"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Foto profil"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Nama unik yang tidak peka huruf besar-kecil untuk mengidentifikasi profil Anda."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Cabut sesi"
|
||||
},
|
||||
|
||||
@@ -5117,27 +5117,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Mai usato"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Una breve descrizione per raccontare a tutti qualcosa di te."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Bio"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Le informazioni del tuo profilo sono visibili pubblicamente su Modrinth e tramite l''<docs-link>API di Modrinth</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Impostazioni del profilo"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Informazioni del profilo"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Foto profilo"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Un nome univoco (maiuscole irrilevanti) per identificare il tuo profilo."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Revoca sessione"
|
||||
},
|
||||
|
||||
@@ -4061,27 +4061,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "未使用"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "あなたのことをみんなに知ってもらうための簡単な自己紹介。"
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "自己紹介"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "あなたのプロフィール情報は、Modrinth上および<docs-link>Modrinth API</docs-link>を通じて一般公開されます。"
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "プロフィール設定"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "プロフィール情報"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "プロフィール写真"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "大文字・小文字を区別しない、プロフィールを識別するための固有の名前。"
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "セッションを取り消す"
|
||||
},
|
||||
|
||||
@@ -5081,27 +5081,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "사용된 적 없음"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "모든 사람에게 당신을 소개하는 간단한 설명입니다."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "자기소개"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "당신의 프로필 정보는 Modrinth 나 <docs-link>Modrinth API</docs-link>를 통해 공개될 수 있습니다."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "프로필 설정"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "프로필 정보"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "프로필 사진"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "대소문자 구분 없이 프로필을 식별할 수 있는 이름"
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "세션 제거"
|
||||
},
|
||||
|
||||
@@ -4103,27 +4103,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Tidak pernah digunakan"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Keterangan ringkas untuk memberitahu semua orang serba sedikit tentang anda."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Bio"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Maklumat profil anda boleh dilihat secara terbuka pada Modrinth dan melalui <docs-link>API Modrinth</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Tetapan profil"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Maklumat profil"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Gambar profil"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Nama unik (tidak sensitif huruf besar dan kecil) untuk mengenal pasti profil anda."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Batalkan sesi"
|
||||
},
|
||||
|
||||
@@ -5141,27 +5141,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Nooit gebruikt"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Een korte beschrijving om iedereen wat over jou te vertellen."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Bio"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Je profielgegevens zijn openbaar op Modrinth en zichtbaar via de <docs-link>Modrinth API</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Profielinstellingen"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Profielgegevens"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Profielfoto"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Een unieke hoofdletterongevoelige naam om je profiel mee te identificeren."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Sessie beëindigen"
|
||||
},
|
||||
|
||||
@@ -3974,27 +3974,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Aldri brukt"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "En kort beskrivelse sånn at alle veit litt om deg."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Bio"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Profilinformasjonen din er offentlig lesbar på Modrinth og gjennom <docs-link>Modrinth API</docs-link>-en."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Profilinnstillinger"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Profilinformasjon"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Profilbilde"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Et unikt navn (som ikke skiller mellom store og små bokstaver) for å identifisere profilen din."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Tilbakekall økta"
|
||||
},
|
||||
|
||||
@@ -5072,27 +5072,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Nigdy nie użyty"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Krótki opis, aby powiedzieć wszystkim trochę o Tobie."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "O mnie"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Twoje informacje profilowe są publicznie dostępnie na Modrinth i poprzez <docs-link>API Modrinth</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Ustawienia konta"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Informacje profilowe"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Zdjęcie profilowe"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Unikatowa bez uwzględniania wielkości liter nazwa identyfikująca Twój profil."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Unieważnij sesję"
|
||||
},
|
||||
|
||||
@@ -5159,27 +5159,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Nunca usado"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Uma breve descrição para falar um pouco sobre você para todos."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Sobre"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "As informações do seu perfil são visíveis publicamente no Modrinth e por meio da <docs-link>API do Modrinth</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Configurações de perfil"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Informação do perfil"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Foto de perfil"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Um nome único sem distinção de maiúsculas e minúsculas para identificar o seu perfil."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Encerrar sessão"
|
||||
},
|
||||
|
||||
@@ -3776,27 +3776,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Nunca usado"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Uma breve descrição para que todos possam saber mais sobre ti."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Biografia"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "As informações do teu perfil são visíveis publicamente no Modrinth e por meio da <docs-link>Modrinth API</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Definições do perfil"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Informação do perfil"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Imagem do perfil"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Um nome único sem distinção de maiúsculas de minúsculas para identificar o teu perfil."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Revogar sessão"
|
||||
},
|
||||
|
||||
@@ -2039,24 +2039,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "N-a fost folosit niciodată"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "O scurtă descriere pentru a le spune tuturor câte ceva despre tine."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Biografie"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Informațiile profilului tău sunt vizibile public pe Modrinth și prin intermediul <docs-link>API-ului Modrinth</docs-link>."
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Informațiile profilului"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Imagine de profil"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Un nume unic care nu ține cont de majuscule, pentru a-ți identifica profilul."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Anulează sesiunea"
|
||||
},
|
||||
|
||||
@@ -5099,27 +5099,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Не использовался"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Возможность кратко рассказать всем о себе."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "О себе"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Информация в вашем профиле доступна публично на Modrinth и через <docs-link>API Modrinth</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Настройки профиля"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Информация в профиле"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Изображение профиля"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Уникальное имя для идентификации профиля. Регистр не имеет значения."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Завершить сеанс"
|
||||
},
|
||||
|
||||
@@ -1973,27 +1973,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Nikada korišćen"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Kratak opis kojim ćete drugima reći nešto o sebi."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Opis"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Informacije na vašem profilu su javno vidljive na Modrinthu i putem <docs-link>Modrinth API-ja</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Podešavanja profila"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Informacije o profilu"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Profilna slika"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Jedinstveno ime, bez razlikovanja velikih i malih slova, koje identifikuje vaš profil."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Opozovi sesiju"
|
||||
},
|
||||
|
||||
@@ -4121,27 +4121,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Aldrig använd"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "En kort beskrivning för att berätta lite om dig själv."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Om mig"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Din profilinformation visas offentligt på Modrinth och genom <docs-link>Modrinth API:t</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Profilinställningar"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Profilinformation"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Profilbild"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Ett unikt skift-känsligt namn för att identifiera din profil."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Återkalla session"
|
||||
},
|
||||
|
||||
@@ -4568,27 +4568,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Hiç kullanılmadı"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Herkese kendinden biraz bahsedebileceğin kısa tanım."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Hakkında"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Profil bilgin Modrinth'te ve <docs-link>Modrinth API</docs-link> herkese açık görüntülenebilir."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Profil Ayarları"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Profil bilgisi"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Profil resmi"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Profilini tanımlamak için büyük-küçük harf hassasiyetli özel bir ad."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Oturumu iptal et"
|
||||
},
|
||||
|
||||
@@ -5081,27 +5081,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Не використовувався"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Короткий опис, щоб розповісти всім трохи про себе."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Про себе"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Інформація з вашого профілю є загальнодоступною на Modrinth та через <docs-link>Modrinth API</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Налаштування профілю"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Інформація про профіль"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Зображення профілю"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Унікальне ім’я без урахування регістру, для ідентифікації вашого профілю."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Завершити сеанс"
|
||||
},
|
||||
|
||||
@@ -4268,27 +4268,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "Chưa từng sử dụng"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "Mô tả ngắn để giới thiệu đôi chút về bạn."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "Tiểu sử"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "Thông tin hồ sơ của bạn được hiển thị công khai trên Modrinth và thông qua <docs-link>API Modrinth</docs-link>."
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "Cài đặt hồ sơ"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "Thông tin hồ sơ"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Ảnh hồ sơ"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "Tên duy nhất (không phân biệt chữ hoa/thường) dùng để nhận diện hồ sơ của bạn."
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "Thu hồi phiên"
|
||||
},
|
||||
|
||||
@@ -5159,27 +5159,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "从未使用"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "做个简短的自我介绍吧。"
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "个人简介"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "你的个人资料信息在 Modrinth 网站及<docs-link>其 API </docs-link>上均公开可见。"
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "个人资料设置"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "个人资料信息"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "头像"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "不区分大小写的唯一名称,用于识别你的个人资料。"
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "登出设备"
|
||||
},
|
||||
|
||||
@@ -5159,27 +5159,6 @@
|
||||
"settings.pats.token.never-used": {
|
||||
"message": "從未使用"
|
||||
},
|
||||
"settings.profile.bio.description": {
|
||||
"message": "簡單介紹一下你自己。"
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"message": "關於我"
|
||||
},
|
||||
"settings.profile.description": {
|
||||
"message": "你的個人檔案資訊會公開顯示在 Modrinth 上,並可透過 <docs-link>Modrinth API</docs-link> 查詢。"
|
||||
},
|
||||
"settings.profile.head-title": {
|
||||
"message": "個人設定"
|
||||
},
|
||||
"settings.profile.profile-info": {
|
||||
"message": "個人檔案資訊"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "頭貼"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "獨特且不區分大小寫的名稱,用於識別你的個人檔案。"
|
||||
},
|
||||
"settings.sessions.action.revoke-session": {
|
||||
"message": "撤銷工作階段"
|
||||
},
|
||||
|
||||
@@ -36,6 +36,13 @@
|
||||
icon: UserIcon,
|
||||
}
|
||||
: null,
|
||||
auth.user
|
||||
? {
|
||||
link: '/settings/social',
|
||||
label: formatMessage(commonSettingsMessages.social),
|
||||
icon: HeartHandshakeIcon,
|
||||
}
|
||||
: null,
|
||||
auth.user
|
||||
? {
|
||||
link: '/settings/account',
|
||||
@@ -99,6 +106,7 @@
|
||||
import {
|
||||
CardIcon,
|
||||
GridIcon,
|
||||
HeartHandshakeIcon,
|
||||
KeyIcon,
|
||||
LanguagesIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
|
||||
@@ -1,276 +1,77 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="card">
|
||||
<h2 class="text-2xl">{{ formatMessage(messages.title) }}</h2>
|
||||
<p class="mb-4">
|
||||
<IntlFormatted :message-id="messages.description">
|
||||
<template #docs-link="{ children }">
|
||||
<a href="https://docs.modrinth.com/" target="_blank" class="text-link">
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<label>
|
||||
<span class="label__title">{{ formatMessage(messages.profilePicture) }}</span>
|
||||
</label>
|
||||
<div class="avatar-changer">
|
||||
<Avatar
|
||||
:src="previewImage ? previewImage : avatarUrl"
|
||||
size="md"
|
||||
circle
|
||||
:alt="auth.user.username"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<ButtonStyled>
|
||||
<FileInput
|
||||
:max-size="262144"
|
||||
:show-icon="true"
|
||||
class="button-like"
|
||||
:prompt="formatMessage(commonMessages.uploadImageButton)"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
@change="showPreviewImage"
|
||||
>
|
||||
<UploadIcon />
|
||||
</FileInput>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="avatarUrl !== null">
|
||||
<button @click="removePreviewImage">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(commonMessages.removeImageButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="previewImage">
|
||||
<button
|
||||
@click="
|
||||
() => {
|
||||
icon = null
|
||||
previewImage = null
|
||||
}
|
||||
"
|
||||
>
|
||||
<UndoIcon />
|
||||
{{ formatMessage(commonMessages.resetButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<label for="username-field">
|
||||
<span class="label__title">{{ formatMessage(commonMessages.usernameLabel) }}</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(messages.usernameDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<StyledInput id="username-field" v-model="current.username" />
|
||||
<div
|
||||
v-if="current.username.length >= 30"
|
||||
id="bio-character-limit"
|
||||
class="inline-block pl-2"
|
||||
:class="{ 'text-red': current.username.length > 39 }"
|
||||
>
|
||||
{{ current.username.length }}/{{ 39 }}
|
||||
</div>
|
||||
<label for="bio-field">
|
||||
<span class="label__title">{{ formatMessage(messages.bioTitle) }}</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(messages.bioDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<StyledInput id="bio-field" v-model="current.bio" multiline />
|
||||
<div id="bio-character-limit" class="pt-2" :class="{ 'text-red': current.bio.length > 160 }">
|
||||
{{ current.bio.length }}/{{ 160 }}
|
||||
</div>
|
||||
<div class="input-group mt-4">
|
||||
<ButtonStyled>
|
||||
<NuxtLink :to="`/user/${auth.user.username}`">
|
||||
<UserIcon /> {{ formatMessage(commonMessages.visitYourProfile) }}
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</section>
|
||||
<UnsavedChangesPopup
|
||||
:original="originalState"
|
||||
:modified="modifiedState"
|
||||
:saving="saving"
|
||||
@reset="reset"
|
||||
@save="save"
|
||||
<section v-if="auth.user" class="universal-card">
|
||||
<AccountProfileSettings
|
||||
ref="profileSettings"
|
||||
:patch-user="patchUser"
|
||||
:change-avatar="changeAvatar"
|
||||
:delete-avatar="deleteAvatar"
|
||||
:get-authenticated-user="getAuthenticatedUser"
|
||||
disclaimer-position="bottom"
|
||||
/>
|
||||
</div>
|
||||
<UnsavedChangesPopup
|
||||
:original="profileSettings?.originalState ?? emptyProfileState"
|
||||
:modified="profileSettings?.modifiedState ?? emptyProfileState"
|
||||
:saving="profileSettings?.saving ?? false"
|
||||
@reset="resetProfileSettings"
|
||||
@save="saveProfileSettings"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TrashIcon, UndoIcon, UploadIcon, UserIcon } from '@modrinth/assets'
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FileInput,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
StyledInput,
|
||||
AccountProfileSettings,
|
||||
commonSettingsMessages,
|
||||
injectModrinthClient,
|
||||
UnsavedChangesPopup,
|
||||
useSavable,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
headTitle: {
|
||||
id: 'settings.profile.head-title',
|
||||
defaultMessage: 'Profile settings',
|
||||
},
|
||||
title: {
|
||||
id: 'settings.profile.profile-info',
|
||||
defaultMessage: 'Profile information',
|
||||
},
|
||||
description: {
|
||||
id: 'settings.profile.description',
|
||||
defaultMessage:
|
||||
'Your profile information is publicly viewable on Modrinth 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.',
|
||||
},
|
||||
})
|
||||
const auth = await useAuth()
|
||||
const client = injectModrinthClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
const profileSettings = ref<InstanceType<typeof AccountProfileSettings> | null>(null)
|
||||
const emptyProfileState = {
|
||||
username: '',
|
||||
bio: '',
|
||||
avatarChanged: false,
|
||||
}
|
||||
|
||||
function patchUser(
|
||||
userId: string,
|
||||
patch: Partial<Pick<Labrinth.Users.v2.User, 'bio' | 'username'>>,
|
||||
): Promise<void> {
|
||||
return client.labrinth.users_v2.patch(userId, patch)
|
||||
}
|
||||
|
||||
function changeAvatar(userId: string, file: Blob, extension: string): Promise<void> {
|
||||
return client.labrinth.users_v2.changeIcon(userId, file, extension)
|
||||
}
|
||||
|
||||
function deleteAvatar(userId: string): Promise<void> {
|
||||
return client.labrinth.users_v2.deleteIcon(userId)
|
||||
}
|
||||
|
||||
async function getAuthenticatedUser(): Promise<Labrinth.Users.v3.User> {
|
||||
const user = await client.labrinth.users_v3.getAuthenticated()
|
||||
auth.value.user = user
|
||||
return user
|
||||
}
|
||||
|
||||
function resetProfileSettings(): void {
|
||||
profileSettings.value?.reset()
|
||||
}
|
||||
|
||||
function saveProfileSettings(): void {
|
||||
void profileSettings.value?.save()
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: () => `${formatMessage(messages.headTitle)} - Modrinth`,
|
||||
title: () => `${formatMessage(commonSettingsMessages.profile)} - Modrinth`,
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
// Avatar state (separate from useSavable)
|
||||
const avatarUrl = ref(auth.value.user.avatar_url)
|
||||
const icon = shallowRef(null)
|
||||
const previewImage = shallowRef(null)
|
||||
const pendingAvatarDeletion = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const {
|
||||
saved,
|
||||
current,
|
||||
reset: resetFields,
|
||||
} = useSavable(
|
||||
() => ({
|
||||
username: auth.value.user.username,
|
||||
bio: auth.value.user.bio ?? '',
|
||||
}),
|
||||
async () => {}, // Save is handled manually due to complex icon logic
|
||||
)
|
||||
|
||||
// Combined state for UnsavedChangesPopup
|
||||
const originalState = computed(() => ({
|
||||
...saved.value,
|
||||
avatarChanged: false,
|
||||
}))
|
||||
|
||||
const modifiedState = computed(() => ({
|
||||
...current.value,
|
||||
avatarChanged: !!(previewImage.value || pendingAvatarDeletion.value),
|
||||
}))
|
||||
|
||||
const reset = () => {
|
||||
resetFields()
|
||||
icon.value = null
|
||||
previewImage.value = null
|
||||
pendingAvatarDeletion.value = false
|
||||
}
|
||||
|
||||
function showPreviewImage(files) {
|
||||
const reader = new FileReader()
|
||||
icon.value = files[0]
|
||||
reader.readAsDataURL(icon.value)
|
||||
reader.onload = (event) => {
|
||||
previewImage.value = event.target.result
|
||||
}
|
||||
}
|
||||
|
||||
function removePreviewImage() {
|
||||
pendingAvatarDeletion.value = true
|
||||
previewImage.value = 'https://cdn.modrinth.com/placeholder.png'
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (pendingAvatarDeletion.value) {
|
||||
await useBaseFetch(`user/${auth.value.user.id}/icon`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
pendingAvatarDeletion.value = false
|
||||
previewImage.value = null
|
||||
}
|
||||
|
||||
if (icon.value) {
|
||||
await useBaseFetch(
|
||||
`user/${auth.value.user.id}/icon?ext=${
|
||||
icon.value.type.split('/')[icon.value.type.split('/').length - 1]
|
||||
}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: icon.value,
|
||||
},
|
||||
)
|
||||
icon.value = null
|
||||
previewImage.value = null
|
||||
}
|
||||
|
||||
const body = {}
|
||||
|
||||
if (auth.value.user.username !== current.value.username) {
|
||||
body.username = current.value.username
|
||||
}
|
||||
|
||||
if (auth.value.user.bio !== current.value.bio) {
|
||||
body.bio = current.value.bio
|
||||
}
|
||||
|
||||
await useBaseFetch(`user/${auth.value.user.id}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
})
|
||||
await useAuth(auth.value.token)
|
||||
avatarUrl.value = auth.value.user.avatar_url
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err
|
||||
? err.data
|
||||
? err.data.description
|
||||
? err.data.description
|
||||
: err.data
|
||||
: err
|
||||
: 'aaaaahhh',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
saving.value = false
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.avatar-changer {
|
||||
display: flex;
|
||||
gap: var(--gap-lg);
|
||||
margin-top: var(--gap-md);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<section v-if="auth.user" class="universal-card">
|
||||
<AccountSocialSettings
|
||||
:get-blocked-users="getBlockedUsers"
|
||||
:get-users="getUsers"
|
||||
:unblock-user="unblockUser"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
AccountSocialSettings,
|
||||
commonSettingsMessages,
|
||||
injectModrinthClient,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
const client = injectModrinthClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
function getBlockedUsers(): Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]> {
|
||||
return client.labrinth.blocked_users_v3.list()
|
||||
}
|
||||
|
||||
function getUsers(userIds: string[]): Promise<Labrinth.Users.v2.User[]> {
|
||||
return client.labrinth.users_v2.getMultiple(userIds)
|
||||
}
|
||||
|
||||
function unblockUser(userId: string): Promise<void> {
|
||||
return client.labrinth.blocked_users_v3.unblock(userId)
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: () => `${formatMessage(commonSettingsMessages.social)} - Modrinth`,
|
||||
})
|
||||
</script>
|
||||
@@ -34,6 +34,9 @@ const userProfile = provideUserProfile({
|
||||
getOrganizations: (userId) => client.labrinth.users_v2.getOrganizations(userId),
|
||||
getCollections: (userId) => client.labrinth.users_v2.getCollections(userId),
|
||||
patchUser: (userId, patch) => client.labrinth.users_v2.patch(userId, patch),
|
||||
getBlockedUsers: () => client.labrinth.blocked_users_v3.list(),
|
||||
blockUser: (userId) => client.labrinth.blocked_users_v3.block(userId),
|
||||
unblockUser: (userId) => client.labrinth.blocked_users_v3.unblock(userId),
|
||||
})
|
||||
const auth = await useAuth()
|
||||
const cosmetics = useCosmetics()
|
||||
|
||||
@@ -24,6 +24,8 @@ import { LabrinthAttributionInternalModule } from './labrinth/attribution/intern
|
||||
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
|
||||
import { LabrinthAuthV2Module } from './labrinth/auth/v2'
|
||||
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
|
||||
import { LabrinthBlockedUsersInternalModule } from './labrinth/blocked-users/internal'
|
||||
import { LabrinthBlockedUsersV3Module } from './labrinth/blocked-users/v3'
|
||||
import { LabrinthCampaignInternalModule } from './labrinth/campaign/internal'
|
||||
import { LabrinthCollectionsModule } from './labrinth/collections'
|
||||
import { LabrinthContentV3Module } from './labrinth/content/v3'
|
||||
@@ -100,6 +102,8 @@ export const MODULE_REGISTRY = {
|
||||
labrinth_auth_v2: LabrinthAuthV2Module,
|
||||
labrinth_attribution_internal: LabrinthAttributionInternalModule,
|
||||
labrinth_billing_internal: LabrinthBillingInternalModule,
|
||||
labrinth_blocked_users_internal: LabrinthBlockedUsersInternalModule,
|
||||
labrinth_blocked_users_v3: LabrinthBlockedUsersV3Module,
|
||||
labrinth_campaign_internal: LabrinthCampaignInternalModule,
|
||||
labrinth_collections: LabrinthCollectionsModule,
|
||||
labrinth_content_v3: LabrinthContentV3Module,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
export class LabrinthBlockedUsersInternalModule extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'labrinth_blocked_users_internal'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether one user has blocked another.
|
||||
*/
|
||||
public async getStatus(
|
||||
userId: string,
|
||||
targetId: string,
|
||||
): Promise<Labrinth.BlockedUsers.Internal.BlockStatus> {
|
||||
return this.client.request<Labrinth.BlockedUsers.Internal.BlockStatus>(
|
||||
`/block/${encodeURIComponent(userId)}/${encodeURIComponent(targetId)}`,
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
export class LabrinthBlockedUsersV3Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'labrinth_blocked_users_v3'
|
||||
}
|
||||
|
||||
/**
|
||||
* List the users blocked by the authenticated user.
|
||||
*/
|
||||
public async list(): Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]> {
|
||||
return this.client.request<Labrinth.BlockedUsers.v3.BlockedUserId[]>('/blocks', {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a user.
|
||||
*
|
||||
* @param idOrUsername - The target user's ID or username
|
||||
*/
|
||||
public async block(idOrUsername: string): Promise<void> {
|
||||
return this.client.request(`/block/${encodeURIComponent(idOrUsername)}`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock a user.
|
||||
*
|
||||
* @param idOrUsername - The target user's ID or username
|
||||
*/
|
||||
public async unblock(idOrUsername: string): Promise<void> {
|
||||
return this.client.request(`/block/${encodeURIComponent(idOrUsername)}`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1691,6 +1691,18 @@ export namespace Labrinth {
|
||||
}
|
||||
}
|
||||
|
||||
export namespace BlockedUsers {
|
||||
export namespace Internal {
|
||||
export type BlockStatus = {
|
||||
blocked: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export namespace v3 {
|
||||
export type BlockedUserId = string
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ServerPing {
|
||||
export namespace Internal {
|
||||
export type MinecraftJavaPingRequest = {
|
||||
|
||||
@@ -168,7 +168,7 @@ export class LabrinthUsersV2Module extends AbstractModule {
|
||||
*/
|
||||
public async patch(
|
||||
idOrUsername: string,
|
||||
data: Partial<Pick<Labrinth.Users.v2.User, 'badges' | 'role'>>,
|
||||
data: Partial<Pick<Labrinth.Users.v2.User, 'badges' | 'bio' | 'role' | 'username'>>,
|
||||
): Promise<void> {
|
||||
return this.client.request(`/user/${idOrUsername}`, {
|
||||
api: 'labrinth',
|
||||
@@ -177,4 +177,34 @@ export class LabrinthUsersV2Module extends AbstractModule {
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a user's avatar.
|
||||
*
|
||||
* @param idOrUsername - The user's ID or username
|
||||
* @param file - Image file to upload
|
||||
* @param ext - File extension (e.g., 'png', 'jpeg', 'gif', 'webp')
|
||||
*/
|
||||
public async changeIcon(idOrUsername: string, file: Blob, ext: string): Promise<void> {
|
||||
return this.client.request(`/user/${idOrUsername}/icon`, {
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
method: 'PATCH',
|
||||
params: { ext },
|
||||
body: file,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user's avatar.
|
||||
*
|
||||
* @param idOrUsername - The user's ID or username
|
||||
*/
|
||||
public async deleteIcon(idOrUsername: string): Promise<void> {
|
||||
return this.client.request(`/user/${idOrUsername}/icon`, {
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::State;
|
||||
use crate::util::fetch::{fetch_advanced, fetch_json};
|
||||
use crate::util::fetch::{fetch_advanced, fetch_advanced_bytes, fetch_json};
|
||||
use bytes::Bytes;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -129,3 +130,114 @@ pub async fn patch_user(user_id: &str, patch: Value) -> crate::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(image))]
|
||||
pub async fn change_user_avatar(
|
||||
user_id: &str,
|
||||
image: Bytes,
|
||||
extension: &str,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let user_id = urlencoding::encode(user_id);
|
||||
let extension = urlencoding::encode(extension);
|
||||
|
||||
fetch_advanced_bytes(
|
||||
Method::PATCH,
|
||||
&format!(
|
||||
"{}user/{}/icon?ext={}",
|
||||
env!("MODRINTH_API_URL"),
|
||||
user_id,
|
||||
extension
|
||||
),
|
||||
image,
|
||||
Some(("Content-Type", "application/octet-stream")),
|
||||
Some("/v2/user/:id/icon"),
|
||||
&state.api_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn delete_user_avatar(user_id: &str) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let user_id = urlencoding::encode(user_id);
|
||||
|
||||
fetch_advanced(
|
||||
Method::DELETE,
|
||||
&format!("{}user/{}/icon", env!("MODRINTH_API_URL"), user_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("/v2/user/:id/icon"),
|
||||
&state.api_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn block_user(user_id: &str) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let user_id = urlencoding::encode(user_id);
|
||||
|
||||
fetch_advanced(
|
||||
Method::POST,
|
||||
&format!("{}block/{}", env!("MODRINTH_API_URL_V3"), user_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("/v3/block/:id"),
|
||||
&state.api_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn unblock_user(user_id: &str) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let user_id = urlencoding::encode(user_id);
|
||||
|
||||
fetch_advanced(
|
||||
Method::DELETE,
|
||||
&format!("{}block/{}", env!("MODRINTH_API_URL_V3"), user_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("/v3/block/:id"),
|
||||
&state.api_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_blocked_users() -> crate::Result<Vec<String>> {
|
||||
let state = State::get().await?;
|
||||
|
||||
fetch_json(
|
||||
Method::GET,
|
||||
&format!("{}blocks", env!("MODRINTH_API_URL_V3")),
|
||||
None,
|
||||
None,
|
||||
Some("/v3/blocks"),
|
||||
&state.api_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -428,6 +428,7 @@ pub async fn fetch_with_client_progress(
|
||||
sha1,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
download_meta,
|
||||
None,
|
||||
uri_path,
|
||||
@@ -493,6 +494,35 @@ pub async fn fetch_advanced(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(body, semaphore))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn fetch_advanced_bytes(
|
||||
method: Method,
|
||||
url: &str,
|
||||
body: Bytes,
|
||||
header: Option<(&str, &str)>,
|
||||
uri_path: Option<&'static str>,
|
||||
semaphore: &FetchSemaphore,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Bytes> {
|
||||
fetch_advanced_with_client_and_progress(
|
||||
method,
|
||||
url,
|
||||
None,
|
||||
None,
|
||||
Some(body),
|
||||
header,
|
||||
None,
|
||||
None,
|
||||
uri_path,
|
||||
semaphore,
|
||||
exec,
|
||||
&INSECURE_REQWEST_CLIENT,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(json_body, semaphore, progress))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn fetch_advanced_with_progress(
|
||||
@@ -513,6 +543,7 @@ pub async fn fetch_advanced_with_progress(
|
||||
url,
|
||||
sha1,
|
||||
json_body,
|
||||
None,
|
||||
header,
|
||||
download_meta,
|
||||
loading_bar,
|
||||
@@ -546,6 +577,7 @@ pub async fn fetch_advanced_with_client(
|
||||
url,
|
||||
sha1,
|
||||
json_body,
|
||||
None,
|
||||
header,
|
||||
download_meta,
|
||||
loading_bar,
|
||||
@@ -558,13 +590,16 @@ pub async fn fetch_advanced_with_client(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(json_body, semaphore, client, progress))]
|
||||
#[tracing::instrument(skip(
|
||||
json_body, bytes_body, semaphore, client, progress
|
||||
))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn fetch_advanced_with_client_and_progress(
|
||||
method: Method,
|
||||
url: &str,
|
||||
sha1: Option<&str>,
|
||||
json_body: Option<serde_json::Value>,
|
||||
bytes_body: Option<Bytes>,
|
||||
header: Option<(&str, &str)>,
|
||||
download_meta: Option<&DownloadMeta>,
|
||||
loading_bar: Option<(&LoadingBarId, f64)>,
|
||||
@@ -611,6 +646,8 @@ async fn fetch_advanced_with_client_and_progress(
|
||||
|
||||
if let Some(body) = json_body.clone() {
|
||||
req = req.json(&body);
|
||||
} else if let Some(body) = bytes_body.clone() {
|
||||
req = req.body(body);
|
||||
}
|
||||
|
||||
if let Some(header) = header {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -4076,9 +4076,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Osobní přístupové tokeny"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Veřejný profil"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Relace"
|
||||
},
|
||||
@@ -4560,4 +4557,3 @@
|
||||
"defaultMessage": "Typ"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2150,9 +2150,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Personlige adgangstokens"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Offentlig profil"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sessioner"
|
||||
},
|
||||
@@ -2241,4 +2238,3 @@
|
||||
"defaultMessage": "Vanilla Shader"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5105,9 +5105,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Persöhnlich Zugangstoken"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Öffentliches Profil"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sitzungen"
|
||||
},
|
||||
@@ -5940,4 +5937,3 @@
|
||||
"defaultMessage": "Typ"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5105,9 +5105,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Persönliche Zugangstoken"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Öffentliches Profil"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sitzungen"
|
||||
},
|
||||
@@ -5940,4 +5937,3 @@
|
||||
"defaultMessage": "Typ"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2861,12 +2861,36 @@
|
||||
"profile.bio.fallback.user": {
|
||||
"defaultMessage": "A Modrinth user."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"profile.block-user.admonition-title": {
|
||||
"defaultMessage": "Are you sure you want to block this user?"
|
||||
},
|
||||
"profile.block-user.error-description": {
|
||||
"defaultMessage": "An error occurred while blocking this user. Please try again."
|
||||
},
|
||||
"profile.block-user.error-title": {
|
||||
"defaultMessage": "Failed to block user"
|
||||
},
|
||||
"profile.block-user.success-description": {
|
||||
"defaultMessage": "{username} has been blocked."
|
||||
},
|
||||
"profile.block-user.success-title": {
|
||||
"defaultMessage": "User blocked"
|
||||
},
|
||||
"profile.block-user.title": {
|
||||
"defaultMessage": "Block {username}"
|
||||
},
|
||||
"profile.button.analytics": {
|
||||
"defaultMessage": "View user analytics"
|
||||
},
|
||||
"profile.button.billing": {
|
||||
"defaultMessage": "Manage user billing"
|
||||
},
|
||||
"profile.button.block": {
|
||||
"defaultMessage": "Block"
|
||||
},
|
||||
"profile.button.create-collection": {
|
||||
"defaultMessage": "Create a collection"
|
||||
},
|
||||
@@ -2888,6 +2912,9 @@
|
||||
"profile.button.set-affiliate": {
|
||||
"defaultMessage": "Set as affiliate"
|
||||
},
|
||||
"profile.button.unblock": {
|
||||
"defaultMessage": "Unblock"
|
||||
},
|
||||
"profile.collection.projects-count": {
|
||||
"defaultMessage": "{count, plural, one {# project} other {# projects}}"
|
||||
},
|
||||
@@ -2972,6 +2999,18 @@
|
||||
"profile.role.update-error-title": {
|
||||
"defaultMessage": "Failed to update role"
|
||||
},
|
||||
"profile.unblock-user.error-description": {
|
||||
"defaultMessage": "An error occurred while unblocking this user. Please try again."
|
||||
},
|
||||
"profile.unblock-user.error-title": {
|
||||
"defaultMessage": "Failed to unblock user"
|
||||
},
|
||||
"profile.unblock-user.success-description": {
|
||||
"defaultMessage": "{username} has been unblocked."
|
||||
},
|
||||
"profile.unblock-user.success-title": {
|
||||
"defaultMessage": "User unblocked"
|
||||
},
|
||||
"project-card.date.published.tooltip": {
|
||||
"defaultMessage": "Published {date}"
|
||||
},
|
||||
@@ -5228,12 +5267,120 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Personal access tokens"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Public profile"
|
||||
"settings.profile.bio.description": {
|
||||
"defaultMessage": "A short description to tell everyone a little bit about you."
|
||||
},
|
||||
"settings.profile.bio.title": {
|
||||
"defaultMessage": "Bio"
|
||||
},
|
||||
"settings.profile.navigation-title": {
|
||||
"defaultMessage": "Profile"
|
||||
},
|
||||
"settings.profile.profile-picture.title": {
|
||||
"defaultMessage": "Profile picture"
|
||||
},
|
||||
"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>."
|
||||
},
|
||||
"settings.profile.save-error": {
|
||||
"defaultMessage": "Failed to update profile"
|
||||
},
|
||||
"settings.profile.save-error-description": {
|
||||
"defaultMessage": "An error occurred while updating your profile. Please try again."
|
||||
},
|
||||
"settings.profile.sign-in-required.description": {
|
||||
"defaultMessage": "Sign in with a Modrinth account to customize your public profile."
|
||||
},
|
||||
"settings.profile.sign-in-required.title": {
|
||||
"defaultMessage": "Modrinth account required"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"defaultMessage": "A unique case-insensitive name to identify your profile."
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sessions"
|
||||
},
|
||||
"settings.social.blocked-users.column.actions": {
|
||||
"defaultMessage": "Actions"
|
||||
},
|
||||
"settings.social.blocked-users.column.user": {
|
||||
"defaultMessage": "User"
|
||||
},
|
||||
"settings.social.blocked-users.description": {
|
||||
"defaultMessage": "These are the users you have blocked on Modrinth. They cannot:"
|
||||
},
|
||||
"settings.social.blocked-users.empty": {
|
||||
"defaultMessage": "You haven't blocked anyone."
|
||||
},
|
||||
"settings.social.blocked-users.load-error": {
|
||||
"defaultMessage": "Blocked users could not be loaded."
|
||||
},
|
||||
"settings.social.blocked-users.loading": {
|
||||
"defaultMessage": "Loading blocked users…"
|
||||
},
|
||||
"settings.social.blocked-users.restriction.friend-requests": {
|
||||
"defaultMessage": "Send you friend requests"
|
||||
},
|
||||
"settings.social.blocked-users.restriction.hosting": {
|
||||
"defaultMessage": "Invite you to manage a Modrinth Hosting server."
|
||||
},
|
||||
"settings.social.blocked-users.restriction.shared-instances": {
|
||||
"defaultMessage": "Invite you to shared instances"
|
||||
},
|
||||
"settings.social.blocked-users.title": {
|
||||
"defaultMessage": "Blocked users"
|
||||
},
|
||||
"settings.social.blocked-users.unblock": {
|
||||
"defaultMessage": "Unblock"
|
||||
},
|
||||
"settings.social.blocked-users.unblock-error": {
|
||||
"defaultMessage": "Failed to unblock user"
|
||||
},
|
||||
"settings.social.blocked-users.unblock-error-description": {
|
||||
"defaultMessage": "An error occurred while unblocking this user. Please try again."
|
||||
},
|
||||
"settings.social.blocked-users.unblock-user": {
|
||||
"defaultMessage": "Unblock {username}"
|
||||
},
|
||||
"settings.social.blocked-users.user-avatar": {
|
||||
"defaultMessage": "{username}'s avatar"
|
||||
},
|
||||
"settings.social.friend-requests.description": {
|
||||
"defaultMessage": "Control who can send you friend requests on Modrinth."
|
||||
},
|
||||
"settings.social.friend-requests.title": {
|
||||
"defaultMessage": "Friend requests"
|
||||
},
|
||||
"settings.social.interaction-source.coming-soon": {
|
||||
"defaultMessage": "Coming soon!"
|
||||
},
|
||||
"settings.social.interaction-source.everyone": {
|
||||
"defaultMessage": "Everyone"
|
||||
},
|
||||
"settings.social.interaction-source.friends": {
|
||||
"defaultMessage": "Friends"
|
||||
},
|
||||
"settings.social.interaction-source.friends-of-friends": {
|
||||
"defaultMessage": "Friends of friends"
|
||||
},
|
||||
"settings.social.interaction-source.no-one": {
|
||||
"defaultMessage": "No one"
|
||||
},
|
||||
"settings.social.shared-instance-invites.description": {
|
||||
"defaultMessage": "Control who can send you invites to shared instances and Modrinth Hosting panels."
|
||||
},
|
||||
"settings.social.shared-instance-invites.title": {
|
||||
"defaultMessage": "Invitations"
|
||||
},
|
||||
"settings.social.sign-in-required.description": {
|
||||
"defaultMessage": "You can control who can interact with you, and manage blocked users with a Modrinth Account"
|
||||
},
|
||||
"settings.social.sign-in-required.title": {
|
||||
"defaultMessage": "Modrinth account required"
|
||||
},
|
||||
"settings.social.title": {
|
||||
"defaultMessage": "Social"
|
||||
},
|
||||
"sharing.invite-players-modal.add": {
|
||||
"defaultMessage": "Add"
|
||||
},
|
||||
|
||||
@@ -5027,9 +5027,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Tokens de acceso personal"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Perfil público"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sesiones"
|
||||
},
|
||||
@@ -5787,4 +5784,3 @@
|
||||
"defaultMessage": "Tipo"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4931,9 +4931,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Tokens de acceso personal"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Perfil público"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sesiones"
|
||||
},
|
||||
|
||||
@@ -560,9 +560,6 @@
|
||||
"settings.language.title": {
|
||||
"defaultMessage": "Kieli"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Julkinen profiili"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Istunnot"
|
||||
},
|
||||
@@ -570,4 +567,3 @@
|
||||
"defaultMessage": "Sinulla on tallentamattomia muutoksia."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2294,9 +2294,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Mga personal na access token"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Pampublikong profile"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Mga sesyon"
|
||||
},
|
||||
@@ -2769,4 +2766,3 @@
|
||||
"defaultMessage": "Uri"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5099,9 +5099,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Jetons d'accès personnel"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Profil public"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sessions"
|
||||
},
|
||||
|
||||
@@ -1127,9 +1127,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "מפתחות גישה אישיים"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "פרופיל ציבורי"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "חיבורים פעילים"
|
||||
},
|
||||
|
||||
@@ -4199,9 +4199,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Személyes hozzáférési tokenek"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Nyilvános profil"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Munkamenetek"
|
||||
},
|
||||
|
||||
@@ -2567,9 +2567,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Token akses pribadi"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Profil publik"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sesi"
|
||||
},
|
||||
@@ -2988,4 +2985,3 @@
|
||||
"defaultMessage": "Anda memiliki perubahan yang belum tersimpan."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5063,9 +5063,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Token di accesso (PAT)"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Profilo pubblico"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sessioni"
|
||||
},
|
||||
|
||||
@@ -4526,9 +4526,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "個人用アクセストークン"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "公開プロフィール"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "セッション"
|
||||
},
|
||||
|
||||
@@ -5039,9 +5039,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "개인 액세스 토큰"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "공개 프로필"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "세션"
|
||||
},
|
||||
|
||||
@@ -4442,9 +4442,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Token akses peribadi"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Profil awam"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sesi"
|
||||
},
|
||||
@@ -5037,4 +5034,3 @@
|
||||
"defaultMessage": "Jenis"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5096,9 +5096,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Persoonlijke toegangstokens"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Openbaar profiel"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sessies"
|
||||
},
|
||||
|
||||
@@ -2423,9 +2423,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Personlig tilgang nøkler"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Offentlig profil"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Økter"
|
||||
},
|
||||
@@ -2781,4 +2778,3 @@
|
||||
"defaultMessage": "Du har ulagrede endringer."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5087,9 +5087,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Klucze dostępu osobistego"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Profil publiczny"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sesje"
|
||||
},
|
||||
|
||||
@@ -5102,9 +5102,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Token de acesso pessoal"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Perfil público"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sessões"
|
||||
},
|
||||
|
||||
@@ -2126,9 +2126,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Tokens de acesso pessoal"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Perfil público"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sessões"
|
||||
},
|
||||
@@ -2529,4 +2526,3 @@
|
||||
"defaultMessage": "Tens alterações por guardar."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3029,9 +3029,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Token-urile personale de acces"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Profil public"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sesiuni"
|
||||
},
|
||||
|
||||
@@ -5018,9 +5018,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Личные токены доступа"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Публичный профиль"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Сеансы"
|
||||
},
|
||||
|
||||
@@ -5027,9 +5027,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Lični tokeni za pristup"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Javni profil"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sesije"
|
||||
},
|
||||
@@ -5787,4 +5784,3 @@
|
||||
"defaultMessage": "Tip"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4340,9 +4340,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Personliga åtkomstnycklar"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Offentlig profil"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sessioner"
|
||||
},
|
||||
@@ -4938,4 +4935,3 @@
|
||||
"defaultMessage": "Typ"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -661,9 +661,5 @@
|
||||
},
|
||||
"settings.language.title": {
|
||||
"defaultMessage": "ภาษา"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "โปรไฟล์สาธารณะ"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5015,9 +5015,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Kişisel erişim tokenleri"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Herkese açık profil"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Oturumlar"
|
||||
},
|
||||
|
||||
@@ -4997,9 +4997,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Особистий токен доступу"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Публічний профіль"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Сеанси"
|
||||
},
|
||||
|
||||
@@ -4448,9 +4448,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Khóa truy cập cá nhân"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Hồ sơ công khai"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Phiên hoạt động"
|
||||
},
|
||||
@@ -5085,4 +5082,3 @@
|
||||
"defaultMessage": "Đội ngũ Modrinth"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5105,9 +5105,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "个人访问令牌"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "公开信息"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "会话"
|
||||
},
|
||||
@@ -5940,4 +5937,3 @@
|
||||
"defaultMessage": "类型"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user