refactor(ui): share user avatar fallbacks (#2143)
This commit is contained in:
+83
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick, ref } from 'vue'
|
||||
|
||||
import ControlsIslandAuthButton from './controls-island-auth-button.vue'
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: ref(true),
|
||||
user: ref<{ name: string, image?: string }>({
|
||||
name: 'Rainbow Bird',
|
||||
image: 'https://example.com/broken-avatar.png',
|
||||
}),
|
||||
needsLogin: ref(false),
|
||||
credits: ref(9620),
|
||||
}
|
||||
|
||||
vi.mock('@proj-airi/stage-ui/stores/auth', () => ({
|
||||
useAuthStore: () => authState,
|
||||
}))
|
||||
|
||||
vi.mock('@proj-airi/electron-vueuse', () => ({
|
||||
useElectronEventaContext: () => ref({
|
||||
on: vi.fn(),
|
||||
}),
|
||||
useElectronEventaInvoke: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: (store: object) => store,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('controlsIslandAuthButton', () => {
|
||||
const mountedApps: Array<{ app: ReturnType<typeof createApp>, host: HTMLElement }> = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, host } of mountedApps) {
|
||||
app.unmount()
|
||||
host.remove()
|
||||
}
|
||||
mountedApps.length = 0
|
||||
authState.user.value.image = 'https://example.com/broken-avatar.png'
|
||||
})
|
||||
|
||||
function mountComponent() {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const app = createApp({
|
||||
render: () => h(ControlsIslandAuthButton),
|
||||
})
|
||||
app.mount(host)
|
||||
mountedApps.push({ app, host })
|
||||
return host
|
||||
}
|
||||
|
||||
it('renders the shared account fallback when no avatar is available', () => {
|
||||
authState.user.value.image = undefined
|
||||
const host = mountComponent()
|
||||
|
||||
const fallback = host.querySelector('[data-avatar-fallback]')
|
||||
expect(fallback).toBeTruthy()
|
||||
expect(fallback?.firstElementChild?.classList.contains('i-solar:user-circle-bold-duotone')).toBe(true)
|
||||
})
|
||||
|
||||
it('tries the next avatar URL after the authenticated user changes', async () => {
|
||||
const host = mountComponent()
|
||||
const previousImage = host.querySelector('[data-avatar-image]')
|
||||
|
||||
authState.user.value.image = 'https://example.com/new-avatar.png'
|
||||
await nextTick()
|
||||
|
||||
const nextImage = host.querySelector('[data-avatar-image]')
|
||||
expect(nextImage).not.toBe(previousImage)
|
||||
expect(nextImage?.getAttribute('src')).toBe('https://example.com/new-avatar.png')
|
||||
expect(nextImage?.getAttribute('alt')).toBe('')
|
||||
})
|
||||
})
|
||||
+4
-10
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
import { Avatar } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -104,21 +105,14 @@ watch(isAuthenticated, (val) => {
|
||||
]"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div
|
||||
<Avatar
|
||||
:src="userAvatar"
|
||||
:class="[
|
||||
'size-8 shrink-0 overflow-hidden rounded-full',
|
||||
'bg-primary-100 dark:bg-primary-900/40',
|
||||
'flex items-center justify-center',
|
||||
]"
|
||||
>
|
||||
<img
|
||||
v-if="userAvatar"
|
||||
:src="userAvatar"
|
||||
:alt="userName ?? ''"
|
||||
class="size-full object-cover"
|
||||
>
|
||||
<div v-else i-solar:user-check-rounded-bold class="size-4 text-primary-500 dark:text-primary-400" />
|
||||
</div>
|
||||
/>
|
||||
<div class="min-w-0 flex flex-1 flex-col items-start gap-0.5">
|
||||
<span
|
||||
:class="[
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ProfileUser } from '../modules/profile'
|
||||
import { defaultSignInProviders } from '@proj-airi/stage-ui/components/auth'
|
||||
import { useLinkedAccounts } from '@proj-airi/stage-ui/composables'
|
||||
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
|
||||
import { Button, FieldInput } from '@proj-airi/ui'
|
||||
import { Avatar, Button, FieldInput } from '@proj-airi/ui'
|
||||
import { computed, onMounted, reactive, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -328,19 +328,14 @@ function formatLinkedSince(iso: string): string {
|
||||
<section
|
||||
:class="['max-w-sm w-full flex flex-col items-center gap-2 mb-6']"
|
||||
>
|
||||
<div
|
||||
<Avatar
|
||||
:src="avatarUrl"
|
||||
:alt="t('server.auth.profile.avatar.altText')"
|
||||
referrer-policy="no-referrer"
|
||||
:class="[
|
||||
'h-24 w-24 overflow-hidden rounded-full border border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800',
|
||||
]"
|
||||
>
|
||||
<img
|
||||
v-if="avatarUrl"
|
||||
:src="avatarUrl"
|
||||
:alt="t('server.auth.profile.avatar.altText')"
|
||||
:class="['h-full w-full object-cover']"
|
||||
referrerpolicy="no-referrer"
|
||||
>
|
||||
</div>
|
||||
/>
|
||||
<div
|
||||
v-if="usingGravatarFallback"
|
||||
:class="['flex flex-col items-center gap-1 text-center text-xs text-neutral-500']"
|
||||
|
||||
@@ -88,6 +88,20 @@ Line-clamped content container that expands and collapses when the overflowing c
|
||||
|
||||
## Misc
|
||||
|
||||
### Avatar
|
||||
|
||||
Shared user-avatar primitive built on Reka UI. It retries when `src` changes and
|
||||
renders the fallback when the URL is missing, loading, or fails.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `src` | `string \| null \| undefined` | `null` | Avatar image URL |
|
||||
| `alt` | `string \| null \| undefined` | `null` | Accessible image/fallback description; omit for decorative avatars |
|
||||
| `referrerPolicy` | `ImgHTMLAttributes['referrerpolicy']?` | — | Image referrer policy |
|
||||
| `crossOrigin` | `ImgHTMLAttributes['crossorigin']?` | — | Image cross-origin mode |
|
||||
|
||||
**Slots**: `fallback` (optional override for the built-in user icon)
|
||||
|
||||
### Button
|
||||
|
||||
Versatile button with variants, sizes, and states.
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { signOut } from '@proj-airi/stage-ui/libs/auth'
|
||||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
import { Avatar } from '@proj-airi/ui'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { isAuthenticated, user, credits } = storeToRefs(authStore)
|
||||
const { t } = useI18n()
|
||||
|
||||
const userName = computed(() => user.value?.name)
|
||||
const userAvatar = computed(() => user.value?.image)
|
||||
const showDropdown = ref(false)
|
||||
const dropdownRef = ref(null)
|
||||
|
||||
// Fall back to the user-icon placeholder when the avatar URL fails to load
|
||||
// (broken/expired host, network error, hot-linked image taken down). Without
|
||||
// this the header pill renders the browser's default broken-image glyph,
|
||||
// which looks worse than the explicit placeholder we already ship.
|
||||
// Reset on URL change so a fixed URL re-attempts loading.
|
||||
const avatarLoadError = ref(false)
|
||||
watch(userAvatar, () => {
|
||||
avatarLoadError.value = false
|
||||
})
|
||||
|
||||
const formattedCredits = computed(() => credits.value.toLocaleString())
|
||||
|
||||
onClickOutside(dropdownRef, () => {
|
||||
@@ -65,22 +58,17 @@ onClickOutside(dropdownRef, () => {
|
||||
class="flex items-center gap-2 border-2 border-neutral-100/60 rounded-full bg-neutral-50/70 p-1 pl-1 pr-3 backdrop-blur-md transition dark:border-neutral-800/30 dark:bg-neutral-800/70 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
:class="{ 'ring-2 ring-primary-500/20': showDropdown }"
|
||||
aria-haspopup="true"
|
||||
:aria-label="userName || t('settings.pages.account.title')"
|
||||
:aria-expanded="showDropdown ? 'true' : 'false'"
|
||||
@click="showDropdown = !showDropdown"
|
||||
>
|
||||
<img
|
||||
v-if="userAvatar && !avatarLoadError"
|
||||
<Avatar
|
||||
:src="userAvatar"
|
||||
:alt="userName"
|
||||
class="h-7 w-7 rounded-full object-cover"
|
||||
@error="avatarLoadError = true"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="h-7 w-7 flex items-center justify-center rounded-full bg-neutral-200 text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||
>
|
||||
<div class="i-solar:user-bold-duotone text-lg" />
|
||||
</div>
|
||||
:class="[
|
||||
'h-7 w-7 rounded-full',
|
||||
'bg-neutral-200 text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400',
|
||||
]"
|
||||
/>
|
||||
|
||||
<span v-if="userName" class="max-w-[100px] truncate text-sm text-neutral-700 font-medium hidden sm:block dark:text-neutral-200">
|
||||
{{ userName }}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { resolveLinkedAccountOAuthErrorMessageKey, useAnalytics, useLinkedAccoun
|
||||
import { authClient } from '@proj-airi/stage-ui/libs/auth'
|
||||
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
|
||||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
import { Button, FieldInput } from '@proj-airi/ui'
|
||||
import { Avatar, Button, FieldInput } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
|
||||
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
@@ -50,14 +50,6 @@ const gravatarProfileUrl = computed(() => {
|
||||
return `https://gravatar.com/${encodeURIComponent(userEmail.value.trim().toLowerCase())}`
|
||||
})
|
||||
|
||||
// Track avatar load failure so we can fall back to the placeholder icon
|
||||
// instead of rendering an alt-text overflow inside the circle. Resets when
|
||||
// the URL changes so a fixed URL re-attempts loading.
|
||||
const avatarLoadError = ref(false)
|
||||
watch(userAvatar, () => {
|
||||
avatarLoadError.value = false
|
||||
})
|
||||
|
||||
// Locale-aware thousand separator. Bare 5–6 digit numbers are noisy to scan
|
||||
// (e.g. "44965" reads as one block); Intl.NumberFormat respects user locale
|
||||
// (44,965 / 44 965 / 44.965 depending on region) without us having to ship a
|
||||
@@ -473,16 +465,10 @@ async function handleConfirmDelete(event: Event) {
|
||||
about the same account, not a separate concern. -->
|
||||
<section :class="['flex flex-col gap-3 pb-6 border-b border-neutral-200/70 dark:border-neutral-800/60']">
|
||||
<div :class="['flex items-center gap-4 py-2']">
|
||||
<div :class="['size-16 sm:size-20 rounded-full overflow-hidden flex-shrink-0', 'bg-neutral-100 dark:bg-neutral-800', 'flex items-center justify-center']">
|
||||
<img
|
||||
v-if="userAvatar && !avatarLoadError"
|
||||
:src="userAvatar"
|
||||
:alt="userName"
|
||||
:class="['size-full object-cover']"
|
||||
@error="avatarLoadError = true"
|
||||
>
|
||||
<div v-else :class="['i-solar:user-circle-bold-duotone', 'size-10 text-neutral-400']" />
|
||||
</div>
|
||||
<Avatar
|
||||
:src="userAvatar"
|
||||
:class="['size-16 sm:size-20 rounded-full flex-shrink-0', 'bg-neutral-100 dark:bg-neutral-800', 'flex items-center justify-center']"
|
||||
/>
|
||||
<div :class="['flex flex-col gap-0.5 min-w-0']">
|
||||
<span :class="['text-xs text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('settings.pages.account.signedInAs') }}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImgHTMLAttributes } from 'vue'
|
||||
|
||||
import { AvatarFallback, AvatarImage, AvatarRoot } from 'reka-ui'
|
||||
|
||||
interface AvatarProps {
|
||||
/**
|
||||
* Image URL. Missing or failed images render the fallback.
|
||||
*
|
||||
* @default null
|
||||
*/
|
||||
src?: string | null
|
||||
/**
|
||||
* Accessible description for both the image and fallback. Omit it when the
|
||||
* surrounding content already identifies the user and the avatar is decorative.
|
||||
*
|
||||
* @default null
|
||||
*/
|
||||
alt?: string | null
|
||||
/** Referrer policy forwarded to the underlying image request. */
|
||||
referrerPolicy?: ImgHTMLAttributes['referrerpolicy']
|
||||
/** Cross-origin mode forwarded to the underlying image request. */
|
||||
crossOrigin?: ImgHTMLAttributes['crossorigin']
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<AvatarProps>(), {
|
||||
src: null,
|
||||
alt: null,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarRoot
|
||||
:key="props.src ?? ''"
|
||||
:class="['overflow-hidden']"
|
||||
>
|
||||
<AvatarImage
|
||||
v-if="props.src"
|
||||
data-avatar-image
|
||||
:src="props.src"
|
||||
:alt="props.alt ?? ''"
|
||||
:referrer-policy="props.referrerPolicy"
|
||||
:cross-origin="props.crossOrigin"
|
||||
:class="['size-full object-cover']"
|
||||
/>
|
||||
<AvatarFallback
|
||||
data-avatar-fallback
|
||||
role="img"
|
||||
:aria-label="props.alt || undefined"
|
||||
:aria-hidden="props.alt ? undefined : 'true'"
|
||||
:class="['size-full flex items-center justify-center']"
|
||||
>
|
||||
<slot name="fallback">
|
||||
<div :class="['i-solar:user-circle-bold-duotone', 'size-1/2 text-neutral-400']" />
|
||||
</slot>
|
||||
</AvatarFallback>
|
||||
</AvatarRoot>
|
||||
</template>
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as Avatar } from './avatar.vue'
|
||||
export { default as Button } from './button.vue'
|
||||
export { default as Callout } from './callout.vue'
|
||||
export { default as ContainerError } from './container-error.vue'
|
||||
|
||||
Reference in New Issue
Block a user