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

+5 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAvatarImage } from '@/composables/useAvatarImage'
import { initialsOf } from '@/lib/directory'
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(
defineProps<{
name: string
avatarId?: number | null
size?: number
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 fontSize = computed(() => `${Math.round(props.size * 0.36)}px`)
</script>
<template>
<UAvatar
:src="src"
:alt="name"
:text="initialsOf(name)"
aria-hidden="true"
+5 -1
View File
@@ -532,7 +532,11 @@ async function confirmUnlink() {
<template #preview>
<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">
<span class="text-xs text-muted">{{ t('Byline preview') }}</span>
<span class="truncate font-semibold text-highlighted">{{
+5 -1
View File
@@ -236,7 +236,11 @@ function rowClass(index: number) {
@pointerup="onPointerUp"
@pointercancel="endDrag"
/>
<AuthorAvatar :name="author.name" :size="avatarSize" />
<AuthorAvatar
:name="author.name"
:avatar-id="author.avatar_id"
:size="avatarSize"
/>
<span
class="min-w-0 flex-1 truncate font-medium text-highlighted"
:title="author.name"
+7 -1
View File
@@ -35,6 +35,7 @@ const items = computed(() =>
description: author.slug,
value: author.id,
avatarName: author.name,
avatarId: author.avatar_id,
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>
</template>
<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 #empty>
<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"
:key="author.id"
:name="author.name"
:avatar-id="author.avatar_id"
:size="size"
ring
/>
+1 -1
View File
@@ -16,7 +16,7 @@ const emit = defineEmits<{ close: [] }>()
<template>
<DirectoryPanel :title="author.name" read-only @close="emit('close')">
<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">
<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">
+19 -6
View File
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import DirectoryField from '@/components/DirectoryField.vue'
import ImageUploader from '@/components/ImageUploader.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import { useMediaNames } from '@/composables/useMediaNames'
import type { ImageChoice } from '@/lib/imageChoice'
const props = withDefaults(
@@ -25,6 +26,9 @@ const props = withDefaults(
const imageId = defineModel<number | null>({ 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 BUTTON_CLASS =
'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 chooseLabel = computed(() => (hasImage.value ? t('Change image') : t('Choose image')))
const caption = computed(() => {
if (item.value) {
return item.value.filename
if (shownItem.value) {
return shownItem.value.filename
}
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) {
item.value = picked
imageId.value = picked.id
@@ -54,8 +67,8 @@ function remove() {
<div class="flex items-center gap-3">
<template v-if="square">
<MediaThumb
v-if="item"
:item="item"
v-if="shownItem"
:item="shownItem"
size="md"
class="size-16 border border-default"
/>
@@ -70,7 +83,7 @@ function remove() {
: '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" />
</div>
<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 {
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() {
+7 -1
View File
@@ -10,6 +10,7 @@ export interface ReassignOption {
description: string
display: string
avatarName?: string
avatarId?: number | null
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" />
</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>
</USelectMenu>
<template v-if="summary && !selectOpen" #help>
@@ -117,6 +117,38 @@ describe('MediaField', () => {
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 () => {
await mountInApp(MediaField, {
label: 'Avatar',