feat: profile settings xplat

This commit is contained in:
Calum H. (IMB11)
2026-07-28 17:36:26 +01:00
parent 65b0e7a5d0
commit 6ef99c5989
22 changed files with 954 additions and 293 deletions
+5 -2
View File
@@ -152,6 +152,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()
@@ -760,6 +761,8 @@ const sharedInstanceInviteHandler = ref()
const updateToPlayModal = ref()
const modrinthLoginModal = ref()
const appSettingsModal = ref()
provide(appSettingsModalOpenProfileKey, () => appSettingsModal.value?.showProfile())
watch(incompatibilityWarningModal, (modal) => {
if (modal) {
@@ -1574,7 +1577,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</div>
</Transition>
<Suspense>
<AppSettingsModal ref="settingsModal" />
<AppSettingsModal ref="appSettingsModal" />
</Suspense>
<Suspense>
<ModrinthAccountRequiredModal ref="modrinthLoginModal" :request-auth="requestModrinthAuth" />
@@ -1646,7 +1649,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>
@@ -10,6 +10,7 @@ import {
Settings2Icon,
ShieldIcon,
ToggleRightIcon,
UserIcon,
} from '@modrinth/assets'
import {
commonMessages,
@@ -18,13 +19,15 @@ 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'
@@ -34,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'
@@ -100,6 +107,12 @@ const tabs = [
content: FeatureFlagSettings,
developerOnly: true,
},
{
name: commonSettingsMessages.profile,
category: tabCategories.account,
icon: UserIcon,
content: ProfileSettings,
},
{
name: commonSettingsMessages.social,
category: tabCategories.account,
@@ -147,12 +160,62 @@ 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()
@@ -205,12 +268,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>
+13 -1
View File
@@ -50,11 +50,23 @@ 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 })
}
+5 -2
View File
@@ -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,7 +15,7 @@
<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 {
@@ -27,9 +28,11 @@ import {
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({
@@ -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)
}