145 lines
4.7 KiB
Vue
145 lines
4.7 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, useTemplateRef } from 'vue'
|
|
import { authClient } from '@/lib/auth-client.ts'
|
|
import { resizeImage } from '@/lib/image-resize.ts'
|
|
import { fileUpload } from '@/api/file.api.ts'
|
|
import { avatarRemove, avatarSet } from '@/api/user.api.ts'
|
|
import { useSnackbarStore } from '@/stores/snackbar.ts'
|
|
import EditableText from '@/components/EditableText.vue'
|
|
|
|
interface Props {
|
|
user?: {
|
|
readonly id: string
|
|
readonly createdAt: Date
|
|
readonly updatedAt: Date
|
|
readonly email: string
|
|
readonly emailVerified: boolean
|
|
readonly name: string
|
|
readonly image?: string | null | undefined
|
|
}
|
|
}
|
|
|
|
/** The largest avatar is 128px in the profile, so 512px covers HiDPI screens */
|
|
const AVATAR_MAX_SIZE = 512
|
|
/** The server shows only these types inline */
|
|
const AVATAR_TYPES = 'image/png,image/jpeg,image/gif,image/webp'
|
|
|
|
const { user } = defineProps<Props>()
|
|
|
|
const snackbarStore = useSnackbarStore()
|
|
const input = useTemplateRef<HTMLInputElement>('input')
|
|
const avatarSaving = ref(false)
|
|
|
|
const avatar = computed(() => {
|
|
return user?.image ?? undefined
|
|
})
|
|
|
|
const createdAt = computed(() => {
|
|
return user?.createdAt ? new Date(user.createdAt).toLocaleString('ru-RU') : ''
|
|
})
|
|
|
|
function showError(text: string, e: unknown) {
|
|
snackbarStore.add({ title: text, text: e instanceof Error ? e.message : '', color: 'error' })
|
|
}
|
|
|
|
// The avatar is changed by our API, not better-auth, so its 30s session cookie cache
|
|
// is stale: re-read the session from the DB, then let useSession() consumers refetch
|
|
async function refreshSession() {
|
|
await authClient.getSession({ query: { disableCookieCache: true } })
|
|
authClient.$store.notify('$sessionSignal')
|
|
}
|
|
|
|
async function saveName(name: string) {
|
|
const trimmed = name.trim()
|
|
if (!trimmed || trimmed === user?.name) return
|
|
|
|
const { error } = await authClient.updateUser({ name: trimmed })
|
|
if (error) showError('Failed to change name', new Error(error.message))
|
|
}
|
|
|
|
function openPicker() {
|
|
input.value?.click()
|
|
}
|
|
|
|
async function onInputChange(event: Event) {
|
|
const target = event.target as HTMLInputElement
|
|
const file = target.files?.[0]
|
|
// Allow picking the same file again
|
|
target.value = ''
|
|
if (!file) return
|
|
|
|
avatarSaving.value = true
|
|
try {
|
|
const stored = await fileUpload(await resizeImage(file, AVATAR_MAX_SIZE))
|
|
await avatarSet(stored.id)
|
|
await refreshSession()
|
|
} catch (e) {
|
|
showError('Failed to change avatar', e)
|
|
} finally {
|
|
avatarSaving.value = false
|
|
}
|
|
}
|
|
|
|
async function removeAvatar() {
|
|
avatarSaving.value = true
|
|
try {
|
|
await avatarRemove()
|
|
await refreshSession()
|
|
} catch (e) {
|
|
showError('Failed to remove avatar', e)
|
|
} finally {
|
|
avatarSaving.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<v-sheet border class="pa-4 d-flex flex-column ga-2">
|
|
<div v-if="user" class="d-flex ga-4">
|
|
<v-menu>
|
|
<template #activator="{ props: menuProps }">
|
|
<v-hover #default="{ isHovering, props: hoverProps }">
|
|
<v-avatar v-bind="{ ...menuProps, ...hoverProps }" size="128" color="background" class="cursor-pointer">
|
|
<!-- With a default slot v-avatar ignores its image prop, so the image goes in the slot -->
|
|
<v-img v-if="avatar" :src="avatar" alt="" cover />
|
|
<v-icon v-else icon="mdi-account" size="64" />
|
|
<v-overlay
|
|
:model-value="isHovering || avatarSaving"
|
|
contained
|
|
persistent
|
|
scrim="black"
|
|
class="align-center justify-center"
|
|
>
|
|
<v-progress-circular v-if="avatarSaving" indeterminate size="40" width="3" color="white" />
|
|
<v-icon v-else icon="mdi-camera" size="40" color="white" />
|
|
</v-overlay>
|
|
</v-avatar>
|
|
</v-hover>
|
|
</template>
|
|
|
|
<v-list density="compact">
|
|
<v-list-item prepend-icon="mdi-upload" title="Upload photo" :disabled="avatarSaving" @click="openPicker" />
|
|
<v-list-item
|
|
v-if="avatar"
|
|
prepend-icon="mdi-trash-can"
|
|
title="Remove photo"
|
|
base-color="error"
|
|
:disabled="avatarSaving"
|
|
@click="removeAvatar"
|
|
/>
|
|
</v-list>
|
|
</v-menu>
|
|
<input ref="input" type="file" :accept="AVATAR_TYPES" hidden @change="onInputChange" />
|
|
|
|
<div class="flex-grow-1">
|
|
<editable-text class="mb-2" :text="user.name" placeholder="Enter your name" required @changed="saveName" />
|
|
<div>E-mail: {{ user.email }}</div>
|
|
<div>Created At: {{ createdAt }}</div>
|
|
</div>
|
|
</div>
|
|
<v-skeleton-loader v-else type="avatar, list-item-three-line" />
|
|
</v-sheet>
|
|
</template>
|
|
|
|
<style scoped lang="scss"></style>
|