feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally)
This commit is contained in:
1 parent
3d81d2c717
commit
6b8990c55e
239 files changed
+9194
-1354
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user