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:
16
frontend-nuxt/composables/useGravatarUrl.ts
Normal file
16
frontend-nuxt/composables/useGravatarUrl.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import md5 from 'md5'
|
||||
|
||||
/**
|
||||
* Returns Gravatar avatar URL for the given email, or empty string if no email.
|
||||
* Gravatar expects: trim, lowercase, then MD5 hex. Use empty string to show a placeholder (e.g. initial letter) in the UI.
|
||||
*
|
||||
* @param email - User email (optional)
|
||||
* @param size - Avatar size in pixels (default 64; navbar 32, drawer 40, profile 64–80)
|
||||
*/
|
||||
export function useGravatarUrl(email: string | undefined, size?: number): string {
|
||||
const normalized = email?.trim().toLowerCase()
|
||||
if (!normalized) return ''
|
||||
const hash = md5(normalized)
|
||||
const s = size ?? 64
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=${s}&d=identicon`
|
||||
}
|
||||
@@ -99,6 +99,14 @@ export function useMapApi() {
|
||||
return request<MeResponse>('me')
|
||||
}
|
||||
|
||||
async function meUpdate(body: { email?: string }) {
|
||||
await request('me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
/** Public: whether first-time setup (no users) is required. */
|
||||
async function setupRequired(): Promise<{ setupRequired: boolean }> {
|
||||
const res = await fetch(`${apiBase}/setup`, { credentials: 'include' })
|
||||
@@ -225,6 +233,7 @@ export function useMapApi() {
|
||||
login,
|
||||
logout,
|
||||
me,
|
||||
meUpdate,
|
||||
oauthLoginUrl,
|
||||
oauthProviders,
|
||||
setupRequired,
|
||||
|
||||
@@ -19,30 +19,39 @@
|
||||
</NuxtLink>
|
||||
<div
|
||||
v-if="me"
|
||||
class="hidden md:flex items-center gap-2 shrink-0"
|
||||
class="hidden md:flex items-center gap-2 shrink-0 min-h-9"
|
||||
>
|
||||
<div
|
||||
class="tooltip tooltip-bottom shrink-0"
|
||||
:data-tip="live ? 'Connected to live updates' : 'Disconnected'"
|
||||
>
|
||||
<div class="flex items-center shrink-0 min-h-9">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs text-base-content/70"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-base-content/70 leading-none"
|
||||
:class="live ? 'text-success' : 'text-base-content/50'"
|
||||
>
|
||||
<span
|
||||
class="size-2 rounded-full"
|
||||
class="size-2 rounded-full shrink-0"
|
||||
:class="live ? 'bg-success animate-pulse' : 'bg-base-content/40'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ live ? 'Live' : 'Offline' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<div class="dropdown dropdown-end flex items-center">
|
||||
<details ref="userDropdownRef" class="dropdown group">
|
||||
<summary class="btn btn-ghost btn-sm gap-2 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-8">
|
||||
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
|
||||
<summary class="btn btn-ghost btn-sm gap-2 flex items-center min-h-9 h-9 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
|
||||
<div class="avatar">
|
||||
<div class="rounded-full w-8 h-8 overflow-hidden flex items-center justify-center">
|
||||
<img
|
||||
v-if="me.email && !gravatarErrorDesktop"
|
||||
:src="useGravatarUrl(me.email, 32)"
|
||||
alt=""
|
||||
class="w-full h-full object-cover"
|
||||
@error="gravatarErrorDesktop = true"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="bg-primary text-primary-content rounded-full w-8 h-8 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="max-w-[8rem] truncate font-medium">{{ me.username }}</span>
|
||||
@@ -119,9 +128,21 @@
|
||||
<aside class="bg-base-200/95 backdrop-blur-xl min-h-full w-72 p-4 flex flex-col">
|
||||
<!-- Mobile: user + live when logged in -->
|
||||
<div v-if="!isLogin && me" class="flex items-center gap-3 pb-4 mb-2 border-b border-base-300/50">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-10">
|
||||
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
|
||||
<div class="avatar">
|
||||
<div class="rounded-full w-10 h-10 overflow-hidden flex items-center justify-center">
|
||||
<img
|
||||
v-if="me.email && !gravatarErrorDrawer"
|
||||
:src="useGravatarUrl(me.email, 40)"
|
||||
alt=""
|
||||
class="w-full h-full object-cover"
|
||||
@error="gravatarErrorDrawer = true"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="bg-primary text-primary-content rounded-full w-10 h-10 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -185,6 +206,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MeResponse } from '~/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const THEME_KEY = 'hnh-map-theme'
|
||||
@@ -202,7 +225,9 @@ function getInitialDark(): boolean {
|
||||
const title = ref('HnH Map')
|
||||
const dark = ref(false)
|
||||
const live = ref(false)
|
||||
const me = ref<{ username?: string; auths?: string[] } | null>(null)
|
||||
const me = useState<MeResponse | null>('me', () => null)
|
||||
const gravatarErrorDesktop = ref(false)
|
||||
const gravatarErrorDrawer = ref(false)
|
||||
const userDropdownRef = ref<HTMLDetailsElement | null>(null)
|
||||
const drawerCheckboxRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -248,6 +273,14 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => me.value?.email,
|
||||
() => {
|
||||
gravatarErrorDesktop.value = false
|
||||
gravatarErrorDrawer.value = false
|
||||
}
|
||||
)
|
||||
|
||||
function onThemeToggle() {
|
||||
dark.value = !dark.value
|
||||
applyTheme()
|
||||
|
||||
37
frontend-nuxt/package-lock.json
generated
37
frontend-nuxt/package-lock.json
generated
@@ -7,6 +7,7 @@
|
||||
"name": "hnh-map-frontend",
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"md5": "^2.3.0",
|
||||
"nuxt": "^3.21.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
@@ -5705,6 +5706,14 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/charenc": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
|
||||
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"license": "MIT",
|
||||
@@ -6035,6 +6044,14 @@
|
||||
"uncrypto": "^0.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/crypt": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
|
||||
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/css-declaration-sorter": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz",
|
||||
@@ -7924,6 +7941,11 @@
|
||||
"url": "https://github.com/sponsors/brc-dd"
|
||||
}
|
||||
},
|
||||
"node_modules/is-buffer": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
|
||||
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
|
||||
},
|
||||
"node_modules/is-builtin-module": {
|
||||
"version": "5.0.0",
|
||||
"dev": true,
|
||||
@@ -8867,6 +8889,16 @@
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/md5": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
|
||||
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
|
||||
"dependencies": {
|
||||
"charenc": "0.0.2",
|
||||
"crypt": "0.0.2",
|
||||
"is-buffer": "~1.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
|
||||
@@ -8962,8 +8994,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.2",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"md5": "^2.3.0",
|
||||
"nuxt": "^3.21.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface MeResponse {
|
||||
username: string
|
||||
auths: string[]
|
||||
email?: string
|
||||
tokens?: string[]
|
||||
prefix?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user