190 lines
4.9 KiB
Vue
190 lines
4.9 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
|
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
|
import { useAuthors, type Author } from '@/composables/useAuthors'
|
|
import type { Byline } from '@/composables/useArticles'
|
|
import { copy } from '@/lib/directoryCopy'
|
|
|
|
const PAGE_SIZE = 100
|
|
const SEARCH_DELAY_MS = 200
|
|
|
|
type Status = 'loading' | 'ready' | 'error'
|
|
|
|
const props = defineProps<{ authors: Byline[]; canCreate: boolean }>()
|
|
|
|
const emit = defineEmits<{ pick: [author: Author]; create: [name: string] }>()
|
|
|
|
const open = defineModel<boolean>('open', { default: false })
|
|
|
|
const { listAuthors } = useAuthors()
|
|
|
|
const status = ref<Status>('loading')
|
|
const found = ref<Author[]>([])
|
|
const searchTerm = ref('')
|
|
let token = 0
|
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const chosenIds = computed(() => props.authors.map((author) => author.id))
|
|
const query = computed(() => searchTerm.value.trim())
|
|
const items = computed(() =>
|
|
status.value === 'ready'
|
|
? found.value.map((author) => ({
|
|
label: author.name,
|
|
description: author.slug,
|
|
value: author.id,
|
|
avatarName: author.name,
|
|
avatarId: author.avatar_id,
|
|
class: chosenIds.value.includes(author.id) ? 'opacity-60' : undefined,
|
|
}))
|
|
: [],
|
|
)
|
|
const createLabel = computed(() =>
|
|
query.value ? t('Create author "{0}"…', [query.value]) : t('Create author…'),
|
|
)
|
|
const showCreate = computed(() => props.canCreate && status.value !== 'error')
|
|
|
|
async function load() {
|
|
const current = ++token
|
|
status.value = 'loading'
|
|
try {
|
|
const page = await listAuthors({
|
|
q: query.value,
|
|
page: 1,
|
|
size: PAGE_SIZE,
|
|
})
|
|
if (current !== token) {
|
|
return
|
|
}
|
|
found.value = page.items
|
|
status.value = 'ready'
|
|
} catch {
|
|
if (current === token) {
|
|
status.value = 'error'
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearTimer() {
|
|
if (timer !== null) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
}
|
|
|
|
watch(open, (value) => {
|
|
if (value) {
|
|
searchTerm.value = ''
|
|
load()
|
|
return
|
|
}
|
|
clearTimer()
|
|
token++
|
|
})
|
|
|
|
watch(searchTerm, () => {
|
|
if (!open.value) {
|
|
return
|
|
}
|
|
clearTimer()
|
|
timer = setTimeout(load, SEARCH_DELAY_MS)
|
|
})
|
|
|
|
onBeforeUnmount(clearTimer)
|
|
|
|
function onSelect(ids: number[]) {
|
|
const added = ids.find((id) => !chosenIds.value.includes(id))
|
|
const author = found.value.find((candidate) => candidate.id === added)
|
|
if (author) {
|
|
emit('pick', author)
|
|
}
|
|
}
|
|
|
|
function createFromQuery() {
|
|
const name = query.value
|
|
open.value = false
|
|
emit('create', name)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<USelectMenu
|
|
:model-value="chosenIds"
|
|
v-model:open="open"
|
|
v-model:search-term="searchTerm"
|
|
multiple
|
|
ignore-filter
|
|
:portal="false"
|
|
:content="{ position: 'inline' }"
|
|
:items="items"
|
|
value-key="value"
|
|
:search-input="{ placeholder: t('Search authors') }"
|
|
class="w-full"
|
|
:ui="{
|
|
base: 'h-11 rounded-none text-lg ring-(--ui-border-accented) lg:h-10 lg:text-sm',
|
|
trailingIcon: 'size-3.5 text-muted',
|
|
content: 'rounded-none shadow-(--shadow-popover)',
|
|
item: 'min-h-12 gap-2 px-2 py-1 data-[state=checked]:bg-elevated lg:min-h-9',
|
|
itemLabel: 'truncate font-medium',
|
|
itemDescription: 'truncate font-mono text-xs text-muted',
|
|
}"
|
|
@update:model-value="onSelect"
|
|
>
|
|
<template #default>
|
|
<UIcon name="i-lucide-plus" class="size-3.5 shrink-0 text-muted" />
|
|
<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)"
|
|
: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">
|
|
<span class="font-medium text-error">{{ copy('loadAuthorsFailed') }}</span>
|
|
<span class="text-muted">{{ copy('checkConnection') }}</span>
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-rotate-cw"
|
|
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 lg:h-7"
|
|
:ui="{ leadingIcon: 'size-3.5' }"
|
|
@click="load"
|
|
>
|
|
{{ t('Try again') }}
|
|
</UButton>
|
|
</div>
|
|
<div
|
|
v-else-if="status === 'loading'"
|
|
class="flex flex-col gap-2.5 p-2"
|
|
aria-busy="true"
|
|
>
|
|
<USkeleton v-for="n in 3" :key="n" class="h-6 rounded-none bg-accented" />
|
|
</div>
|
|
<div v-else-if="query" class="p-2 text-muted">
|
|
{{ t('No author matches "{0}".', [query]) }}
|
|
</div>
|
|
<div v-else class="p-2 text-muted">{{ t('No authors yet') }}</div>
|
|
</template>
|
|
<template #content-bottom>
|
|
<UButton
|
|
v-if="showCreate"
|
|
color="neutral"
|
|
variant="ghost"
|
|
icon="i-lucide-plus"
|
|
block
|
|
data-testid="create-author-row"
|
|
class="min-h-12 justify-start gap-2 rounded-none border-t border-default px-2 py-1 font-medium text-highlighted lg:min-h-9"
|
|
:ui="{ leadingIcon: 'size-4', label: 'truncate' }"
|
|
@click="createFromQuery"
|
|
>
|
|
{{ createLabel }}
|
|
</UButton>
|
|
</template>
|
|
</USelectMenu>
|
|
</template>
|