Enhance user profile management and Gravatar integration

- Added email field to user profile API and frontend components for better user identification.
- Implemented PATCH /map/api/me endpoint to update user email, enhancing user experience.
- Introduced useGravatarUrl composable for generating Gravatar URLs based on user email.
- Updated profile and layout components to display user avatars using Gravatar, improving visual consistency.
- Enhanced development documentation to guide testing of navbar and profile features.
This commit is contained in:
2026-03-01 16:48:56 +03:00
parent db0b48774a
commit 6a6977ddff
14 changed files with 311 additions and 28 deletions

View File

@@ -16,9 +16,21 @@
</template>
<template v-else-if="me">
<div class="flex flex-wrap items-center gap-4">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-14">
<span class="text-2xl font-semibold">{{ (me.username || '?')[0].toUpperCase() }}</span>
<div class="avatar">
<div class="rounded-full w-14 h-14 overflow-hidden flex items-center justify-center">
<img
v-if="me.email && !gravatarError"
:src="useGravatarUrl(me.email, 80)"
alt=""
class="w-full h-full object-cover"
@error="gravatarError = true"
>
<div
v-else
class="bg-primary text-primary-content rounded-full w-14 h-14 flex items-center justify-center"
>
<span class="text-2xl font-semibold">{{ (me.username || '?')[0].toUpperCase() }}</span>
</div>
</div>
</div>
<div class="flex flex-col gap-1">
@@ -33,6 +45,34 @@
</span>
<span v-if="!me.auths?.length" class="text-sm text-base-content/60">No roles</span>
</div>
<p class="text-sm text-base-content/80 mt-1">
Email: {{ me.email || 'Not set' }}
</p>
<div v-if="!emailEditing" class="flex items-center gap-2 mt-1">
<button
type="button"
class="btn btn-ghost btn-xs"
@click="startEditEmail"
>
{{ me.email ? 'Edit' : 'Set' }} email
</button>
</div>
<form v-else class="flex flex-wrap items-center gap-2 mt-2" @submit.prevent="saveEmail">
<input
v-model="emailEdit"
type="email"
placeholder="email@example.com"
class="input input-bordered input-sm w-full max-w-xs"
autocomplete="email"
>
<button type="submit" class="btn btn-primary btn-sm" :disabled="loadingEmail">
{{ loadingEmail ? '…' : 'Save' }}
</button>
<button type="button" class="btn btn-ghost btn-sm" :disabled="loadingEmail" @click="cancelEditEmail">
Cancel
</button>
<p v-if="emailError" class="text-error text-sm w-full">{{ emailError }}</p>
</form>
</div>
</div>
</template>
@@ -104,10 +144,12 @@
</template>
<script setup lang="ts">
import type { MeResponse } from '~/types/api'
const api = useMapApi()
const toast = useToast()
const initialLoad = ref(true)
const me = ref<{ username?: string; auths?: string[] } | null>(null)
const me = useState<MeResponse | null>('me', () => null)
const tokens = ref<string[]>([])
const uploadPrefix = ref('')
const newPass = ref('')
@@ -117,8 +159,41 @@ const passMsg = ref('')
const passOk = ref(false)
const tokenError = ref('')
const copiedToken = ref<string | null>(null)
const gravatarError = ref(false)
const emailEditing = ref(false)
const emailEdit = ref('')
const loadingEmail = ref(false)
const emailError = ref('')
let copiedTimeout: ReturnType<typeof setTimeout> | null = null
function startEditEmail() {
emailError.value = ''
emailEdit.value = me.value?.email ?? ''
emailEditing.value = true
}
function cancelEditEmail() {
emailEditing.value = false
emailError.value = ''
}
async function saveEmail() {
emailError.value = ''
loadingEmail.value = true
try {
await api.meUpdate({ email: emailEdit.value.trim() || undefined })
const data = await api.me()
me.value = data
emailEditing.value = false
gravatarError.value = false
toast.success('Email updated')
} catch (e: unknown) {
emailError.value = e instanceof Error ? e.message : 'Failed to update email'
} finally {
loadingEmail.value = false
}
}
function uploadTokenDisplay(token: string): string {
const base = (uploadPrefix.value ?? '').replace(/\/+$/, '')
return base ? `${base}/client/${token}` : `client/${token}`
@@ -142,7 +217,7 @@ async function copyToken(token: string) {
onMounted(async () => {
try {
const data = await api.me()
me.value = { username: data.username, auths: data.auths }
me.value = data
tokens.value = data.tokens ?? []
uploadPrefix.value = data.prefix ?? ''
} catch {
@@ -159,9 +234,10 @@ async function generateToken() {
loadingTokens.value = true
try {
await api.meTokens()
const me = await api.me()
tokens.value = me.tokens ?? []
uploadPrefix.value = me.prefix ?? ''
const data = await api.me()
me.value = data
tokens.value = data.tokens ?? []
uploadPrefix.value = data.prefix ?? ''
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : ''
tokenError.value = msg === 'Forbidden'