feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally)

This commit is contained in:
Florian Sylvain committed 2026-10-15 05:25:44 +02:00
1 parent 3d81d2c717
commit 6b8990c55e
239 files changed
+9194 -1354

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
<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,
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)" :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>