411 lines
10 KiB
Vue
411 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
|
|
import { computed, ref, watchPostEffect } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
|
import SeoStatusChip from '@/components/SeoStatusChip.vue'
|
|
import AuthorStack from '@/components/AuthorStack.vue'
|
|
import type { Site } from '@/composables/useSites'
|
|
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
|
|
import type { Article } from '@/composables/useArticles'
|
|
import { seoChipOf } from '@/lib/articleSeoStatus'
|
|
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
articles: Article[]
|
|
loading: boolean
|
|
skeletonRows?: number
|
|
sort?: string
|
|
busyIds: Set<number>
|
|
sites?: Site[]
|
|
now?: Date
|
|
}>(),
|
|
{ skeletonRows: 6, sort: DEFAULT_ARTICLE_SORT, sites: () => [] },
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
publish: [id: number]
|
|
unpublish: [id: number]
|
|
delete: [article: Article]
|
|
'copy-url': [id: number]
|
|
sort: [field: ArticleSortField]
|
|
}>()
|
|
|
|
const router = useRouter()
|
|
|
|
const SINGLE_SITE_WIDTHS =
|
|
'[&:nth-child(2)]:w-26 [&:nth-child(3)]:w-27.5 [&:nth-child(4)]:w-32.5 [&:nth-child(5)]:w-35 [&:nth-child(6)]:w-11'
|
|
const SEVERAL_SITES_WIDTHS =
|
|
'[&:nth-child(2)]:w-26 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-27.5 [&:nth-child(5)]:w-32.5 [&:nth-child(6)]:w-35 [&:nth-child(7)]:w-11'
|
|
|
|
const rows = computed(() =>
|
|
props.loading
|
|
? Array.from(
|
|
{ length: props.skeletonRows },
|
|
(_, index) => ({ id: -(index + 1) }) as Article,
|
|
)
|
|
: props.articles,
|
|
)
|
|
|
|
const showSite = computed(() => props.sites.length > 1)
|
|
const columnWidths = computed(() => (showSite.value ? SEVERAL_SITES_WIDTHS : SINGLE_SITE_WIDTHS))
|
|
|
|
const sorting = computed(() => {
|
|
const [id, direction] = props.sort.split(':')
|
|
return [{ id: id!, desc: direction === 'desc' }]
|
|
})
|
|
|
|
const tableRoot = ref<HTMLElement | null>(null)
|
|
const SORTABLE_COLUMNS = ['title', 'status', 'created_at', 'updated_at']
|
|
|
|
watchPostEffect(() => {
|
|
const [active, direction] = props.sort.split(':')
|
|
tableRoot.value?.querySelectorAll('thead th').forEach((header, index) => {
|
|
if (SORTABLE_COLUMNS.includes(active!) && columns.value[index]?.id === active) {
|
|
header.setAttribute('aria-sort', direction === 'desc' ? 'descending' : 'ascending')
|
|
} else {
|
|
header.removeAttribute('aria-sort')
|
|
}
|
|
})
|
|
})
|
|
|
|
const COLUMN_NAMES = {
|
|
title: () => t('title'),
|
|
status: () => t('status'),
|
|
created_at: () => t('created'),
|
|
updated_at: () => t('updated'),
|
|
}
|
|
|
|
const caption = computed(() => {
|
|
if (props.sort === DEFAULT_ARTICLE_SORT) {
|
|
return t('Articles, sorted by last updated, newest first')
|
|
}
|
|
const [field, direction] = props.sort.split(':') as [ArticleSortField, string]
|
|
return t('Articles, sorted by {0}, {1}', [
|
|
COLUMN_NAMES[field](),
|
|
direction === 'desc' ? t('descending') : t('ascending'),
|
|
])
|
|
})
|
|
|
|
const columns = computed(() => [
|
|
{
|
|
accessorKey: 'title',
|
|
id: 'title',
|
|
get header() {
|
|
return t('Title')
|
|
},
|
|
enableSorting: false,
|
|
},
|
|
{
|
|
id: 'authors',
|
|
get header() {
|
|
return t('Authors')
|
|
},
|
|
enableSorting: false,
|
|
},
|
|
...(showSite.value
|
|
? [
|
|
{
|
|
id: 'site',
|
|
get header() {
|
|
return t('Site')
|
|
},
|
|
enableSorting: false,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
accessorKey: 'is_online',
|
|
id: 'status',
|
|
get header() {
|
|
return t('Status')
|
|
},
|
|
size: 110,
|
|
enableSorting: false,
|
|
},
|
|
{
|
|
accessorKey: 'created_at',
|
|
id: 'created_at',
|
|
get header() {
|
|
return t('Created')
|
|
},
|
|
size: 130,
|
|
enableSorting: false,
|
|
},
|
|
{
|
|
accessorKey: 'updated_at',
|
|
id: 'updated_at',
|
|
get header() {
|
|
return t('Updated')
|
|
},
|
|
size: 140,
|
|
enableSorting: false,
|
|
},
|
|
{ id: 'actions', header: '', size: 44 },
|
|
])
|
|
|
|
function siteNameOf(article: Article) {
|
|
return props.sites.find((site) => site.id === article.site_id)?.name ?? ''
|
|
}
|
|
|
|
function authorNames(article: Article) {
|
|
return (article.authors ?? []).map((author) => author.name).join(', ')
|
|
}
|
|
|
|
function sortIcon(field: string) {
|
|
const [id, direction] = props.sort.split(':')
|
|
if (id !== field) {
|
|
return undefined
|
|
}
|
|
return direction === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
|
|
}
|
|
|
|
function editPath(article: Article) {
|
|
return `/article/${article.id}/edit`
|
|
}
|
|
|
|
function isBusy(article: Article) {
|
|
return props.busyIds.has(article.id)
|
|
}
|
|
|
|
function pendingFor(article: Article) {
|
|
if (!isBusy(article)) {
|
|
return undefined
|
|
}
|
|
return article.is_online ? ('unpublish' as const) : ('publish' as const)
|
|
}
|
|
|
|
function actionsFor(article: Article) {
|
|
return [
|
|
[
|
|
{
|
|
get label() {
|
|
return t('Edit')
|
|
},
|
|
icon: 'i-lucide-pencil',
|
|
onSelect: () => router.push(editPath(article)),
|
|
},
|
|
{
|
|
get label() {
|
|
return article.is_online ? t('Unpublish') : t('Publish')
|
|
},
|
|
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
|
|
disabled: isBusy(article),
|
|
onSelect: () => {
|
|
if (article.is_online) {
|
|
emit('unpublish', article.id)
|
|
} else {
|
|
emit('publish', article.id)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
get label() {
|
|
return t('Copy API URL')
|
|
},
|
|
icon: 'i-lucide-link',
|
|
onSelect: () => emit('copy-url', article.id),
|
|
},
|
|
],
|
|
[
|
|
{
|
|
get label() {
|
|
return t('Delete…')
|
|
},
|
|
icon: 'i-lucide-trash-2',
|
|
color: 'error' as const,
|
|
onSelect: () => emit('delete', article),
|
|
},
|
|
],
|
|
]
|
|
}
|
|
|
|
function onSelect(_event: Event, row: { original: Article }) {
|
|
if (props.loading) {
|
|
return
|
|
}
|
|
router.push(editPath(row.original))
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="tableRoot"
|
|
:aria-busy="loading ? 'true' : undefined"
|
|
class="hidden border border-default bg-default md:block"
|
|
>
|
|
<span v-if="loading" role="status" class="sr-only">{{ t('Loading articles') }}</span>
|
|
<UTable
|
|
:data="rows"
|
|
:columns="columns"
|
|
:ui="{
|
|
base: 'table-fixed',
|
|
tr: 'cursor-pointer',
|
|
th: `px-3 ${columnWidths}`,
|
|
td: `h-11 px-3 py-0 ${columnWidths}`,
|
|
}"
|
|
:sorting="sorting"
|
|
:sorting-options="{ manualSorting: true }"
|
|
@select="onSelect"
|
|
>
|
|
<template #caption>
|
|
<caption class="sr-only">
|
|
{{
|
|
caption
|
|
}}
|
|
</caption>
|
|
</template>
|
|
|
|
<template #title-header="{ column }">
|
|
<button
|
|
type="button"
|
|
:disabled="loading"
|
|
class="inline-flex items-center gap-1 text-highlighted"
|
|
@click.stop="emit('sort', column.id as ArticleSortField)"
|
|
>
|
|
{{ t('Title') }}
|
|
<UIcon
|
|
v-if="sortIcon(column.id)"
|
|
:name="sortIcon(column.id)!"
|
|
:data-sort-direction="props.sort.split(':')[1]"
|
|
class="size-3"
|
|
/>
|
|
</button>
|
|
</template>
|
|
|
|
<template #status-header="{ column }">
|
|
<button
|
|
type="button"
|
|
:disabled="loading"
|
|
class="inline-flex items-center gap-1 text-highlighted"
|
|
@click.stop="emit('sort', column.id as ArticleSortField)"
|
|
>
|
|
{{ t('Status') }}
|
|
<UIcon
|
|
v-if="sortIcon(column.id)"
|
|
:name="sortIcon(column.id)!"
|
|
:data-sort-direction="props.sort.split(':')[1]"
|
|
class="size-3"
|
|
/>
|
|
</button>
|
|
</template>
|
|
|
|
<template #created_at-header="{ column }">
|
|
<button
|
|
type="button"
|
|
:disabled="loading"
|
|
class="inline-flex items-center gap-1 text-highlighted"
|
|
@click.stop="emit('sort', column.id as ArticleSortField)"
|
|
>
|
|
{{ t('Created') }}
|
|
<UIcon
|
|
v-if="sortIcon(column.id)"
|
|
:name="sortIcon(column.id)!"
|
|
:data-sort-direction="props.sort.split(':')[1]"
|
|
class="size-3"
|
|
/>
|
|
</button>
|
|
</template>
|
|
|
|
<template #updated_at-header="{ column }">
|
|
<button
|
|
type="button"
|
|
:disabled="loading"
|
|
class="inline-flex items-center gap-1 text-highlighted"
|
|
@click.stop="emit('sort', column.id as ArticleSortField)"
|
|
>
|
|
{{ t('Updated') }}
|
|
<UIcon
|
|
v-if="sortIcon(column.id)"
|
|
:name="sortIcon(column.id)!"
|
|
:data-sort-direction="props.sort.split(':')[1]"
|
|
class="size-3"
|
|
/>
|
|
</button>
|
|
</template>
|
|
|
|
<template #title-cell="{ row }">
|
|
<USkeleton v-if="loading" class="h-3 w-3/5 rounded-none bg-accented" />
|
|
<RouterLink
|
|
v-else
|
|
:to="editPath(row.original)"
|
|
:data-row-title="row.original.id"
|
|
class="font-medium text-highlighted"
|
|
@click.stop
|
|
>
|
|
{{ row.original.title }}
|
|
</RouterLink>
|
|
</template>
|
|
|
|
<template #authors-cell="{ row }">
|
|
<USkeleton v-if="loading" class="h-6 w-15 rounded-none bg-accented" />
|
|
<span v-else class="flex min-w-0 items-center" :title="authorNames(row.original)">
|
|
<AuthorStack
|
|
v-if="row.original.authors?.length"
|
|
:authors="row.original.authors"
|
|
/>
|
|
<span v-else :aria-label="t('No authors')" class="text-dimmed">—</span>
|
|
</span>
|
|
</template>
|
|
|
|
<template #site-cell="{ row }">
|
|
<USkeleton v-if="loading" class="h-3 w-17.5 rounded-none bg-accented" />
|
|
<span v-else class="block truncate text-toned">{{ siteNameOf(row.original) }}</span>
|
|
</template>
|
|
|
|
<template #status-cell="{ row }">
|
|
<USkeleton v-if="loading" class="h-5 w-15 rounded-none bg-accented" />
|
|
<span v-else class="flex items-center gap-1.5">
|
|
<ArticleStatusBadge
|
|
:online="row.original.is_online"
|
|
size="md"
|
|
:pending="pendingFor(row.original)"
|
|
:aria-busy="isBusy(row.original) ? 'true' : undefined"
|
|
/>
|
|
<SeoStatusChip
|
|
v-if="seoChipOf(row.original)"
|
|
:chip="seoChipOf(row.original)!"
|
|
/>
|
|
</span>
|
|
</template>
|
|
|
|
<template #created_at-cell="{ row }">
|
|
<USkeleton v-if="loading" class="h-3 w-20 rounded-none bg-accented" />
|
|
<span v-else class="text-muted">{{
|
|
formatAbsoluteDateFromIso(row.original.created_at)
|
|
}}</span>
|
|
</template>
|
|
|
|
<template #updated_at-cell="{ row }">
|
|
<USkeleton v-if="loading" class="h-3 w-20 rounded-none bg-accented" />
|
|
<span v-else class="text-muted" :title="row.original.updated_at">{{
|
|
formatRelativeDate(row.original.updated_at, now)
|
|
}}</span>
|
|
</template>
|
|
|
|
<template #actions-cell="{ row }">
|
|
<UDropdownMenu
|
|
v-if="!loading"
|
|
:items="actionsFor(row.original)"
|
|
:content="{ align: 'end' }"
|
|
>
|
|
<UButton
|
|
icon="i-lucide-ellipsis"
|
|
color="neutral"
|
|
variant="ghost"
|
|
:aria-label="t('Actions for {0}', [row.original.title])"
|
|
class="size-8 justify-center text-toned"
|
|
@click.stop
|
|
/>
|
|
</UDropdownMenu>
|
|
</template>
|
|
|
|
<template #empty>
|
|
<slot name="empty" />
|
|
</template>
|
|
</UTable>
|
|
</div>
|
|
</template>
|