feat: articles pagination

This commit is contained in:
Florian Sylvain committed 2026-10-11 19:14:37 +02:00
1 parent a2d224240c
commit 73f4b539fe
45 files changed
+3528 -317

No files matched your search

+95 -34
View File
@@ -1,24 +1,31 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import { computed, ref, watchPostEffect } from 'vue'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
const props = defineProps<{
articles: Article[]
loading: boolean
busyIds: Set<number>
now?: Date
}>()
const props = withDefaults(
defineProps<{
articles: Article[]
loading: boolean
skeletonRows?: number
sort?: string
busyIds: Set<number>
now?: Date
}>(),
{ skeletonRows: 5, sort: DEFAULT_ARTICLE_SORT },
)
const emit = defineEmits<{
publish: [id: number]
unpublish: [id: number]
delete: [article: Article]
'copy-url': [id: number]
sort: [field: ArticleSortField]
}>()
const router = useRouter()
@@ -26,7 +33,42 @@ const router = useRouter()
const columnWidths =
'[&:nth-child(2)]:w-27.5 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-35 [&:nth-child(5)]:w-11'
const sorting = ref([{ id: 'updated_at', desc: true }])
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[index] === 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 = [
{
@@ -34,15 +76,16 @@ const columns = [
get header() {
return t('Title')
},
enableSorting: true,
enableSorting: false,
},
{
accessorKey: 'is_online',
id: 'status',
get header() {
return t('Status')
},
size: 110,
enableSorting: true,
enableSorting: false,
},
{
accessorKey: 'created_at',
@@ -50,7 +93,7 @@ const columns = [
return t('Created')
},
size: 130,
enableSorting: true,
enableSorting: false,
},
{
accessorKey: 'updated_at',
@@ -58,20 +101,17 @@ const columns = [
return t('Updated')
},
size: 140,
enableSorting: true,
enableSorting: false,
},
{ id: 'actions', header: '', size: 44 },
]
function sortIcon(column: { getIsSorted: () => false | 'asc' | 'desc' }) {
const sorted = column.getIsSorted()
if (sorted === 'asc') {
return 'i-lucide-arrow-up'
function sortIcon(field: string) {
const [id, direction] = props.sort.split(':')
if (id !== field) {
return undefined
}
if (sorted === 'desc') {
return 'i-lucide-arrow-down'
}
return undefined
return direction === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
}
function editPath(article: Article) {
@@ -143,7 +183,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
<span role="status" class="sr-only">{{ t('Loading articles') }}</span>
<div
v-for="n in 5"
v-for="n in skeletonRows"
:key="n"
class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0"
>
@@ -153,7 +193,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
</div>
</div>
<div v-else class="hidden border border-default bg-default md:block">
<div v-else ref="tableRoot" class="hidden border border-default bg-default md:block">
<UTable
:data="articles"
:columns="columns"
@@ -163,13 +203,14 @@ function onSelect(_event: Event, row: { original: Article }) {
th: `px-3 ${columnWidths}`,
td: `h-11 px-3 py-0 ${columnWidths}`,
}"
v-model:sorting="sorting"
:sorting="sorting"
:sorting-options="{ manualSorting: true }"
@select="onSelect"
>
<template #caption>
<caption class="sr-only">
{{
t('Articles, sorted by last updated, newest first')
caption
}}
</caption>
</template>
@@ -178,21 +219,31 @@ function onSelect(_event: Event, row: { original: Article }) {
<button
type="button"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
{{ t('Title') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
<UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button>
</template>
<template #is_online-header="{ column }">
<template #status-header="{ column }">
<button
type="button"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
{{ t('Status') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
<UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button>
</template>
@@ -200,10 +251,15 @@ function onSelect(_event: Event, row: { original: Article }) {
<button
type="button"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
{{ t('Created') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
<UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button>
</template>
@@ -211,10 +267,15 @@ function onSelect(_event: Event, row: { original: Article }) {
<button
type="button"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
{{ t('Updated') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
<UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button>
</template>
@@ -229,7 +290,7 @@ function onSelect(_event: Event, row: { original: Article }) {
</RouterLink>
</template>
<template #is_online-cell="{ row }">
<template #status-cell="{ row }">
<ArticleStatusBadge
:online="row.original.is_online"
size="sm"