fix: authors profile pictute all accross the SPA

This commit is contained in:
Florian Sylvain committed 2026-10-19 00:46:16 +02:00
1 parent 18600ab49d
commit 500ad59458
19 files changed
+265 -16

No files matched your search

+1
View File
@@ -514,6 +514,7 @@ Feature: Sites and Authors screens
and "Shown as a square. Choose an image with the face or logo near the middle." and "Use image" and "Shown as a square. Choose an image with the face or logo near the middle." and "Use image"
When the user picks an image and activates "Use image" When the user picks an image and activates "Use image"
Then the avatar shows as a square and avatar_id is sent on save Then the avatar shows as a square and avatar_id is sent on save
And once saved, the avatar is the picked image in the list row, the read-only detail, the edit form and its byline preview, resolved with GET /v1/media/{avatar_id} and shared between all of them, with the initials only while it loads, when it fails or when avatar_id is null
And the Sites default social image uses this same picker and sends default_social_image_id And the Sites default social image uses this same picker and sends default_social_image_id
Scenario Outline: Authors form: save failed Scenario Outline: Authors form: save failed
+1
View File
@@ -87,6 +87,7 @@ Authors: form (11):
- [ ] Linked user: choose - [ ] Linked user: choose
- [ ] Edit: linked author - [ ] Edit: linked author
- [ ] Avatar: picker - [ ] Avatar: picker
- [ ] Avatar: saved image shows in the list, the detail, the reopened form and the preview (not only the initials)
- [ ] Save failed - [ ] Save failed
- [ ] Unlink: confirm - [ ] Unlink: confirm
- [ ] Re-link - [ ] Re-link
+5 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useAvatarImage } from '@/composables/useAvatarImage'
import { initialsOf } from '@/lib/directory' import { initialsOf } from '@/lib/directory'
const TONES = ['bg-(--rc-sand)', 'bg-(--ui-bg-accented)', 'bg-(--ui-bg-elevated)'] as const const TONES = ['bg-(--rc-sand)', 'bg-(--ui-bg-accented)', 'bg-(--ui-bg-elevated)'] as const
@@ -7,18 +8,21 @@ const TONES = ['bg-(--rc-sand)', 'bg-(--ui-bg-accented)', 'bg-(--ui-bg-elevated)
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
name: string name: string
avatarId?: number | null
size?: number size?: number
ring?: boolean ring?: boolean
}>(), }>(),
{ size: 32, ring: false }, { avatarId: null, size: 32, ring: false },
) )
const src = useAvatarImage(() => props.avatarId)
const tone = computed(() => TONES[props.name.length % TONES.length]) const tone = computed(() => TONES[props.name.length % TONES.length])
const fontSize = computed(() => `${Math.round(props.size * 0.36)}px`) const fontSize = computed(() => `${Math.round(props.size * 0.36)}px`)
</script> </script>
<template> <template>
<UAvatar <UAvatar
:src="src"
:alt="name" :alt="name"
:text="initialsOf(name)" :text="initialsOf(name)"
aria-hidden="true" aria-hidden="true"
+5 -1
View File
@@ -532,7 +532,11 @@ async function confirmUnlink() {
<template #preview> <template #preview>
<div class="flex items-start gap-3 bg-elevated p-3"> <div class="flex items-start gap-3 bg-elevated p-3">
<AuthorAvatar :name="values.name || 'Author'" :size="40" /> <AuthorAvatar
:name="values.name || 'Author'"
:avatar-id="values.avatarId"
:size="40"
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5"> <div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="text-xs text-muted">{{ t('Byline preview') }}</span> <span class="text-xs text-muted">{{ t('Byline preview') }}</span>
<span class="truncate font-semibold text-highlighted">{{ <span class="truncate font-semibold text-highlighted">{{
+5 -1
View File
@@ -236,7 +236,11 @@ function rowClass(index: number) {
@pointerup="onPointerUp" @pointerup="onPointerUp"
@pointercancel="endDrag" @pointercancel="endDrag"
/> />
<AuthorAvatar :name="author.name" :size="avatarSize" /> <AuthorAvatar
:name="author.name"
:avatar-id="author.avatar_id"
:size="avatarSize"
/>
<span <span
class="min-w-0 flex-1 truncate font-medium text-highlighted" class="min-w-0 flex-1 truncate font-medium text-highlighted"
:title="author.name" :title="author.name"
+7 -1
View File
@@ -35,6 +35,7 @@ const items = computed(() =>
description: author.slug, description: author.slug,
value: author.id, value: author.id,
avatarName: author.name, avatarName: author.name,
avatarId: author.avatar_id,
class: chosenIds.value.includes(author.id) ? 'opacity-60' : undefined, class: chosenIds.value.includes(author.id) ? 'opacity-60' : undefined,
})) }))
: [], : [],
@@ -135,7 +136,12 @@ function createFromQuery() {
<span class="flex-1 truncate text-left text-muted">{{ t('Add an author') }}</span> <span class="flex-1 truncate text-left text-muted">{{ t('Add an author') }}</span>
</template> </template>
<template #item-leading="{ item }"> <template #item-leading="{ item }">
<AuthorAvatar v-if="'avatarName' in item" :name="String(item.avatarName)" :size="24" /> <AuthorAvatar
v-if="'avatarName' in item"
:name="String(item.avatarName)"
:avatar-id="'avatarId' in item ? item.avatarId : null"
:size="24"
/>
</template> </template>
<template #empty> <template #empty>
<div v-if="status === 'error'" class="flex flex-col items-start gap-2 p-2"> <div v-if="status === 'error'" class="flex flex-col items-start gap-2 p-2">
+1
View File
@@ -26,6 +26,7 @@ const extra = computed(() => Math.max(0, props.authors.length - props.max))
v-for="author in shown" v-for="author in shown"
:key="author.id" :key="author.id"
:name="author.name" :name="author.name"
:avatar-id="author.avatar_id"
:size="size" :size="size"
ring ring
/> />
+1 -1
View File
@@ -16,7 +16,7 @@ const emit = defineEmits<{ close: [] }>()
<template> <template>
<DirectoryPanel :title="author.name" read-only @close="emit('close')"> <DirectoryPanel :title="author.name" read-only @close="emit('close')">
<div class="flex items-center gap-3.5"> <div class="flex items-center gap-3.5">
<AuthorAvatar :name="author.name" :size="64" /> <AuthorAvatar :name="author.name" :avatar-id="author.avatar_id" :size="64" />
<div class="flex min-w-0 flex-1 flex-col gap-1"> <div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="truncate font-mono text-xs/4.5 text-muted">{{ author.slug }}</span> <span class="truncate font-mono text-xs/4.5 text-muted">{{ author.slug }}</span>
<div class="flex items-center gap-2 text-xs text-muted"> <div class="flex items-center gap-2 text-xs text-muted">
+19 -6
View File
@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import AuthorAvatar from '@/components/AuthorAvatar.vue' import AuthorAvatar from '@/components/AuthorAvatar.vue'
import DirectoryField from '@/components/DirectoryField.vue' import DirectoryField from '@/components/DirectoryField.vue'
import ImageUploader from '@/components/ImageUploader.vue' import ImageUploader from '@/components/ImageUploader.vue'
import MediaThumb from '@/components/MediaThumb.vue' import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia' import type { MediaItem } from '@/composables/useMedia'
import { useMediaNames } from '@/composables/useMediaNames'
import type { ImageChoice } from '@/lib/imageChoice' import type { ImageChoice } from '@/lib/imageChoice'
const props = withDefaults( const props = withDefaults(
@@ -25,6 +26,9 @@ const props = withDefaults(
const imageId = defineModel<number | null>({ default: null }) const imageId = defineModel<number | null>({ default: null })
const item = defineModel<MediaItem | null>('item', { default: null }) const item = defineModel<MediaItem | null>('item', { default: null })
const names = useMediaNames()
const shownItem = computed(() => item.value ?? names.itemOf(imageId.value))
const pickerOpen = ref(false) const pickerOpen = ref(false)
const BUTTON_CLASS = const BUTTON_CLASS =
'h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7' 'h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7'
@@ -32,12 +36,21 @@ const BUTTON_CLASS =
const hasImage = computed(() => imageId.value !== null) const hasImage = computed(() => imageId.value !== null)
const chooseLabel = computed(() => (hasImage.value ? t('Change image') : t('Choose image'))) const chooseLabel = computed(() => (hasImage.value ? t('Change image') : t('Choose image')))
const caption = computed(() => { const caption = computed(() => {
if (item.value) { if (shownItem.value) {
return item.value.filename return shownItem.value.filename
} }
return imageId.value === null ? '' : t('Image #{0}', [imageId.value]) return imageId.value === null ? '' : t('Image #{0}', [imageId.value])
}) })
function resolveMissing() {
if (!item.value) {
names.resolve([imageId.value])
}
}
onMounted(resolveMissing)
watch(imageId, resolveMissing)
function choose(picked: MediaItem) { function choose(picked: MediaItem) {
item.value = picked item.value = picked
imageId.value = picked.id imageId.value = picked.id
@@ -54,8 +67,8 @@ function remove() {
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<template v-if="square"> <template v-if="square">
<MediaThumb <MediaThumb
v-if="item" v-if="shownItem"
:item="item" :item="shownItem"
size="md" size="md"
class="size-16 border border-default" class="size-16 border border-default"
/> />
@@ -70,7 +83,7 @@ function remove() {
: 'border-dashed border-accented bg-default' : 'border-dashed border-accented bg-default'
" "
> >
<MediaThumb v-if="item" :item="item" size="sm" class="size-full" /> <MediaThumb v-if="shownItem" :item="shownItem" size="sm" class="size-full" />
<UIcon v-else name="i-lucide-image" class="size-4.5" /> <UIcon v-else name="i-lucide-image" class="size-4.5" />
</div> </div>
<div class="flex min-w-0 flex-1 flex-col gap-1.5"> <div class="flex min-w-0 flex-1 flex-col gap-1.5">
+1 -1
View File
@@ -58,7 +58,7 @@ const showHint = computed(
) )
function toByline(author: Author): Byline { function toByline(author: Author): Byline {
return { id: author.id, name: author.name, slug: author.slug } return { id: author.id, name: author.name, slug: author.slug, avatar_id: author.avatar_id }
} }
function dismissHint() { function dismissHint() {
+7 -1
View File
@@ -10,6 +10,7 @@ export interface ReassignOption {
description: string description: string
display: string display: string
avatarName?: string avatarName?: string
avatarId?: number | null
none?: boolean none?: boolean
} }
@@ -100,7 +101,12 @@ const current = computed(() => props.options.find((option) => option.value === s
> >
<UIcon name="i-lucide-user-x" class="size-3.5" /> <UIcon name="i-lucide-user-x" class="size-3.5" />
</span> </span>
<AuthorAvatar v-else-if="item.avatarName" :name="item.avatarName" :size="24" /> <AuthorAvatar
v-else-if="item.avatarName"
:name="item.avatarName"
:avatar-id="item.avatarId"
:size="24"
/>
</template> </template>
</USelectMenu> </USelectMenu>
<template v-if="summary && !selectOpen" #help> <template v-if="summary && !selectOpen" #help>
@@ -117,6 +117,38 @@ describe('MediaField', () => {
expect(wrapper.findComponent(MediaField).emitted('update:modelValue')![0]).toEqual([null]) expect(wrapper.findComponent(MediaField).emitted('update:modelValue')![0]).toEqual([null])
}) })
it('looks up the saved image by id and shows its filename when reopened without an item', async () => {
const request = vi.fn().mockResolvedValue(ITEM)
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request } as unknown as ReturnType<
typeof useApi
>)
await mountInApp(MediaField, { label: 'Avatar', picker: PICKER, modelValue: 55 })
await flushPromises()
expect(request).toHaveBeenCalledWith('/v1/media/55')
expect(text(document.body)).toContain('face.jpg')
expect(text(document.body)).not.toContain('Image #55')
expect(document.body.querySelector('[data-testid="media-thumb"] img')).toBeTruthy()
})
it('does not look the image up when the item is already known', async () => {
const request = vi.fn()
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request } as unknown as ReturnType<
typeof useApi
>)
await mountInApp(MediaField, {
label: 'Avatar',
picker: PICKER,
modelValue: 55,
item: ITEM,
})
await flushPromises()
expect(request).not.toHaveBeenCalled()
})
it('draws the avatar variant as a square with initials when no image is chosen', async () => { it('draws the avatar variant as a square with initials when no image is chosen', async () => {
await mountInApp(MediaField, { await mountInApp(MediaField, {
label: 'Avatar', label: 'Avatar',
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope, nextTick, ref } from 'vue'
const request = vi.fn()
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: () => ({ request }) }
})
import { useAvatarImage } from '@/composables/useAvatarImage'
async function settle() {
await nextTick()
await Promise.resolve()
await nextTick()
}
describe('useAvatarImage', () => {
beforeEach(() => {
request.mockReset()
request.mockImplementation(async (path: string) => ({
id: Number(path.split('/').pop()),
url: `/static/uploadedImages/${path.split('/').pop()}.jpg`,
}))
})
it('resolves the avatar id to the media url', async () => {
const src = effectScope().run(() => useAvatarImage(() => 101))!
await settle()
expect(request).toHaveBeenCalledWith('/v1/media/101')
expect(src.value).toBe('/static/uploadedImages/101.jpg')
})
it('has no image and makes no request without an avatar id', async () => {
const src = effectScope().run(() => useAvatarImage(() => null))!
await settle()
expect(request).not.toHaveBeenCalled()
expect(src.value).toBeUndefined()
})
it('shares one lookup between avatars of the same image', async () => {
effectScope().run(() => {
useAvatarImage(() => 102)
useAvatarImage(() => 102)
})
await settle()
expect(request).toHaveBeenCalledTimes(1)
})
it('follows the id when the author picks another image', async () => {
const id = ref(103)
const src = effectScope().run(() => useAvatarImage(() => id.value))!
await settle()
id.value = 104
await settle()
expect(src.value).toBe('/static/uploadedImages/104.jpg')
})
it('falls back to initials and does not retry when the lookup fails', async () => {
request.mockRejectedValue(new Error('gone'))
const src = effectScope().run(() => useAvatarImage(() => 105))!
effectScope().run(() => useAvatarImage(() => 105))
await settle()
expect(src.value).toBeUndefined()
expect(request).toHaveBeenCalledTimes(1)
})
})
+38
View File
@@ -0,0 +1,38 @@
import { computed, reactive, watchEffect } from 'vue'
import { useApi } from '@/composables/useApi'
import type { MediaItem } from '@/composables/useMedia'
const urls = reactive<Record<number, string>>({})
const pending = new Set<number>()
const failed = new Set<number>()
function rememberAvatarImage(item: Pick<MediaItem, 'id' | 'url'>) {
urls[item.id] = item.url
}
export function useAvatarImage(avatarId: () => number | null | undefined) {
const { request } = useApi()
async function lookUp(id: number) {
pending.add(id)
try {
rememberAvatarImage(await request<MediaItem>(`/v1/media/${id}`))
} catch {
failed.add(id)
} finally {
pending.delete(id)
}
}
watchEffect(() => {
const id = avatarId()
if (typeof id === 'number' && !(id in urls) && !pending.has(id) && !failed.has(id)) {
lookUp(id)
}
})
return computed(() => {
const id = avatarId()
return typeof id === 'number' ? urls[id] : undefined
})
}
+1 -1
View File
@@ -33,5 +33,5 @@ export function sameIds(left: Byline[], right: Byline[]) {
} }
export function toBylines(authors: Byline[]): Byline[] { export function toBylines(authors: Byline[]): Byline[] {
return authors.map(({ id, name, slug }) => ({ id, name, slug })) return authors.map(({ id, name, slug, avatar_id }) => ({ id, name, slug, avatar_id }))
} }
+1
View File
@@ -2,4 +2,5 @@ export interface Byline {
id: number id: number
name: string name: string
slug: string slug: string
avatar_id?: number | null
} }
+3 -2
View File
@@ -357,6 +357,7 @@ async function loadDeleteOptions(author: Author) {
description: candidate.slug, description: candidate.slug,
display: candidate.name, display: candidate.name,
avatarName: candidate.name, avatarName: candidate.name,
avatarId: candidate.avatar_id,
})), })),
nobodyOption(), nobodyOption(),
] ]
@@ -668,7 +669,7 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q
> >
<template #cells="{ item }"> <template #cells="{ item }">
<div class="flex min-w-0 items-center gap-2.5"> <div class="flex min-w-0 items-center gap-2.5">
<AuthorAvatar :name="item.name" :size="32" /> <AuthorAvatar :name="item.name" :avatar-id="item.avatar_id" :size="32" />
<span class="truncate font-medium text-highlighted" :title="item.name">{{ <span class="truncate font-medium text-highlighted" :title="item.name">{{
item.name item.name
}}</span> }}</span>
@@ -680,7 +681,7 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q
<span class="text-muted">{{ pluralArticles(item.article_count) }}</span> <span class="text-muted">{{ pluralArticles(item.article_count) }}</span>
</template> </template>
<template #card="{ item }"> <template #card="{ item }">
<AuthorAvatar :name="item.name" :size="40" /> <AuthorAvatar :name="item.name" :avatar-id="item.avatar_id" :size="40" />
<div class="flex min-w-0 flex-1 flex-col gap-0.75"> <div class="flex min-w-0 flex-1 flex-col gap-0.75">
<span <span
class="truncate text-md/5.25 font-medium text-highlighted" class="truncate text-md/5.25 font-medium text-highlighted"
@@ -49,6 +49,40 @@ function manyAuthors(count: number): Author[] {
})) }))
} }
describe('Authors: avatar image', () => {
const AVATAR_ID = 77
const AVATAR_URL = '/static/uploadedImages/camille.jpg'
function backendWithAvatar() {
const backend = createBackend()
backend.authors = backend.authors.map((author) =>
author.slug === 'camille-roche' ? { ...author, avatar_id: AVATAR_ID } : author,
)
backend.responders[`GET /v1/media/${AVATAR_ID}`] = () => ({
id: AVATAR_ID,
url: AVATAR_URL,
})
return backend
}
it('draws the saved avatar in the row of the author that has one', async () => {
await mountDirectory('authors', '/authors', { backend: backendWithAvatar() })
await flushPromises()
const images = Array.from(
document.body.querySelectorAll<HTMLImageElement>('[data-testid="directory-row"] img'),
)
expect(images.map((image) => image.getAttribute('src'))).toEqual([AVATAR_URL])
})
it('keeps the initials for authors without an avatar', async () => {
await mountDirectory('authors', '/authors')
await flushPromises()
expect(document.body.querySelectorAll('[data-testid="directory-row"] img')).toHaveLength(0)
})
})
describe.each(['owner', 'admin'])('Authors: default for %s', (role) => { describe.each(['owner', 'admin'])('Authors: default for %s', (role) => {
it('shows the header, the search field, the columns and the rows', async () => { it('shows the header, the search field, the columns and the rows', async () => {
const backend = await mountDirectory('authors', '/authors', { role }) const backend = await mountDirectory('authors', '/authors', { role })
@@ -79,6 +79,31 @@ describe('Author list: an ordered list with the first author as the primary byli
}) })
}) })
describe('Author list: avatars', () => {
const AVATAR_ID = 61
const AVATAR_URL = '/static/uploadedImages/ines.jpg'
function rowImages() {
return authorRows().map((row) => row.querySelector('img')?.getAttribute('src') ?? null)
}
it('draws the avatar of an author loaded with the article and keeps the initials of the others', async () => {
const backend = backendWith('Inès Duarte', 'Jonas Pereira')
backend.article.authors = backend.article.authors!.map((author, index) =>
index === 0 ? { ...author, avatar_id: AVATAR_ID } : author,
)
backend.responders[`GET /v1/media/${AVATAR_ID}`] = () => ({
id: AVATAR_ID,
url: AVATAR_URL,
})
await mountMeta('editor', EDITOR, { backend })
await flushPromises()
expect(rowImages()).toEqual([AVATAR_URL, null])
})
})
describe('Author list: reorder with the keyboard', () => { describe('Author list: reorder with the keyboard', () => {
async function mountThree() { async function mountThree() {
const backend = backendWith('Inès Duarte', 'Jonas Pereira', 'Camille Roche') const backend = backendWith('Inès Duarte', 'Jonas Pereira', 'Camille Roche')