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

+287 -90
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import DashboardShell from '@/components/DashboardShell.vue'
@@ -10,12 +10,34 @@ import ArticleCardList from '@/components/ArticleCardList.vue'
import EmptyState from '@/components/EmptyState.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import AppPaginator from '@/components/AppPaginator.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { useArticles, type Article } from '@/composables/useArticles'
import {
useArticles,
type Article,
type ArticleCounts,
type ArticleStatus,
} from '@/composables/useArticles'
import { bodyImageCount } from '@/lib/bodyImageIds'
import {
effectiveSort,
nextSort,
parseArticleQuery,
type ArticleSortField,
sameArticleQuery,
sameRouteQuery,
toRouteQuery,
type ArticleListQuery,
} from '@/lib/articleListQuery'
import { pageCountFor } from '@/lib/pagination'
const SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120
const DEFAULT_SKELETON_ROWS = 5
const ARTICLES_PATH = '/article'
type UrlMode = 'push' | 'replace'
type Notice = { kind: 'range' } | { kind: 'error'; query: ArticleListQuery; mode: UrlMode }
const route = useRoute()
const router = useRouter()
@@ -24,51 +46,40 @@ const { listArticles } = useArticles()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
const articles = ref<Article[]>([])
const items = ref<Article[]>([])
const total = ref(0)
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
const shown = ref<ArticleListQuery>(parseArticleQuery(route.query))
const desired = ref<ArticleListQuery>(shown.value)
const hasLoaded = ref(false)
const loading = ref(true)
const loadFailed = ref(false)
const pendingPage = ref<number | null>(null)
const notice = ref<Notice | null>(null)
const showSkeleton = ref(false)
const busyIds = ref<Set<number>>(new Set())
const newArticleModalOpen = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
let requestToken = 0
const searchInput = ref(String(route.query.q ?? ''))
const debouncedSearch = ref(searchInput.value)
const searchInput = ref(shown.value.q)
function normalize(value: string) {
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
function clearSearchTimer() {
if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
watch(searchInput, (value) => {
if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer)
}
searchDebounceTimer = setTimeout(() => {
debouncedSearch.value = value
router.replace({
query: { ...route.query, q: value || undefined },
})
}, SEARCH_DEBOUNCE_MS)
clearSearchTimer()
searchDebounceTimer = setTimeout(() => changeSearch(value), SEARCH_DEBOUNCE_MS)
})
const activeTab = computed<'' | 'online' | 'draft'>({
get: () =>
route.query.status === 'online' || route.query.status === 'draft' ? route.query.status : '',
set: (value) => {
router.replace({
query: { ...route.query, status: value || undefined },
})
},
})
const counts = computed(() => {
const online = articles.value.filter((article) => article.is_online).length
return {
all: articles.value.length,
online,
drafts: articles.value.length - online,
}
const activeTab = computed<'' | ArticleStatus>({
get: () => desired.value.status,
set: (value) => changeStatus(value),
})
const tabItems = computed(() => [
@@ -86,46 +97,44 @@ const tabItems = computed(() => [
},
{
get label() {
return t('Drafts {0}', [counts.value.drafts])
return t('Drafts {0}', [counts.value.draft])
},
value: 'draft',
},
])
const statusFilteredArticles = computed(() => {
if (activeTab.value === 'online') {
return articles.value.filter((article) => article.is_online)
}
if (activeTab.value === 'draft') {
return articles.value.filter((article) => !article.is_online)
}
return articles.value
})
const displayedArticles = computed(() => {
const search = normalize(debouncedSearch.value.trim())
if (!search) {
return statusFilteredArticles.value
}
return statusFilteredArticles.value.filter((article) =>
normalize(article.title).includes(search),
)
})
const settled = computed(() => hasLoaded.value && !loadFailed.value)
const skeletonActive = computed(() => loading.value && showSkeleton.value)
const skeletonRows = computed(() => items.value.length || DEFAULT_SKELETON_ROWS)
const emptyVariant = computed(() => {
if (loading.value || loadFailed.value) {
if (!settled.value || skeletonActive.value || total.value > 0) {
return null
}
if (articles.value.length === 0) {
return 'no-articles' as const
}
if (debouncedSearch.value.trim() && displayedArticles.value.length === 0) {
if (shown.value.q) {
return 'no-results' as const
}
if (statusFilteredArticles.value.length === 0) {
return activeTab.value === 'draft' ? ('no-drafts' as const) : ('no-online' as const)
if (shown.value.status === 'draft') {
return 'no-drafts' as const
}
return null
if (shown.value.status === 'online') {
return 'no-online' as const
}
return 'no-articles' as const
})
const showPaginator = computed(() => settled.value && total.value > 0)
const pageCount = computed(() => pageCountFor(total.value, shown.value.size))
const noticeText = computed(() => {
const current = notice.value
if (!current) {
return ''
}
if (current.kind === 'range') {
return t("That page doesn't exist. Showing the last page.")
}
return t("Couldn't load page {0}. Showing page {1}.", [current.query.page, shown.value.page])
})
function clearSkeletonTimer() {
@@ -135,27 +144,125 @@ function clearSkeletonTimer() {
}
}
async function loadArticles() {
async function syncUrl(query: ArticleListQuery, mode: UrlMode) {
if (route.path !== ARTICLES_PATH) {
return
}
const target = toRouteQuery(query)
if (sameRouteQuery(route.query, target)) {
return
}
await router[mode]({ query: target })
}
async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notice | null = null) {
const token = ++requestToken
desired.value = query
loading.value = true
showSkeleton.value = false
pendingPage.value = hasLoaded.value ? query.page : null
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
loadFailed.value = false
try {
articles.value = await listArticles()
const response = await listArticles(query)
if (token !== requestToken) {
return
}
const lastPage = pageCountFor(response.total, query.size)
if (
successNotice === null &&
response.items.length === 0 &&
response.total > 0 &&
query.page > lastPage
) {
await load({ ...query, page: lastPage }, 'replace', { kind: 'range' })
return
}
const effective = { ...query, page: response.total === 0 ? 1 : query.page }
items.value = response.items
total.value = response.total
counts.value = response.counts
shown.value = effective
desired.value = effective
hasLoaded.value = true
loadFailed.value = false
notice.value = successNotice
await syncUrl(effective, mode)
} catch {
articles.value = []
loadFailed.value = true
if (token !== requestToken) {
return
}
if (hasLoaded.value) {
notice.value = { kind: 'error', query, mode }
} else {
items.value = []
loadFailed.value = true
}
} finally {
loading.value = false
clearSkeletonTimer()
showSkeleton.value = false
if (token === requestToken) {
loading.value = false
pendingPage.value = null
clearSkeletonTimer()
showSkeleton.value = false
}
}
}
function changePage(page: number) {
load({ ...desired.value, page }, 'push')
}
function changeSize(size: number) {
load({ ...desired.value, size, page: 1 }, 'push')
}
function changeStatus(status: '' | ArticleStatus) {
if (status === desired.value.status) {
return
}
load({ ...desired.value, status, page: 1 }, 'push')
}
function changeSort(field: ArticleSortField) {
load({ ...desired.value, sort: nextSort(desired.value.sort, field), page: 1 }, 'push')
}
function changeSearch(value: string) {
const q = value.trim()
if (q === desired.value.q) {
return
}
load({ ...desired.value, q, page: 1 }, 'replace')
}
function retryNotice() {
if (notice.value?.kind === 'error') {
load(notice.value.query, notice.value.mode)
}
}
watch(
() => route.query,
() => {
if (route.path !== ARTICLES_PATH) {
return
}
const next = parseArticleQuery(route.query)
if (hasLoaded.value && sameArticleQuery(next, shown.value)) {
syncUrl(shown.value, 'replace')
return
}
if (sameArticleQuery(next, desired.value) && loading.value) {
return
}
searchInput.value = next.q
load(next, 'replace')
},
)
function setBusy(id: number, busy: boolean) {
const next = new Set(busyIds.value)
if (busy) {
@@ -166,8 +273,46 @@ function setBusy(id: number, busy: boolean) {
busyIds.value = next
}
function removeArticle(id: number) {
articles.value = articles.value.filter((article) => article.id !== id)
function shiftCounts(article: Article, delta: number) {
const key = article.is_online ? 'online' : 'draft'
counts.value = {
...counts.value,
all: Math.max(0, counts.value.all + delta),
[key]: Math.max(0, counts.value[key] + delta),
}
}
function detachRow(id: number) {
if (!items.value.some((article) => article.id === id)) {
return
}
items.value = items.value.filter((article) => article.id !== id)
total.value = Math.max(0, total.value - 1)
if (items.value.length === 0 && total.value > 0) {
const page = Math.min(shown.value.page, pageCountFor(total.value, shown.value.size))
load({ ...shown.value, page }, 'replace')
}
}
function removeArticle(article: Article) {
shiftCounts(article, -1)
detachRow(article.id)
}
function replaceArticle(updated: Article, previous: Article) {
items.value = items.value.map((existing) => (existing.id === updated.id ? updated : existing))
if (updated.is_online === previous.is_online) {
return
}
counts.value = {
...counts.value,
online: counts.value.online + (updated.is_online ? 1 : -1),
draft: counts.value.draft + (updated.is_online ? -1 : 1),
}
const filter = shown.value.status
if (filter && (filter === 'online') !== updated.is_online) {
detachRow(updated.id)
}
}
function notFoundToast(article: Article) {
@@ -191,9 +336,7 @@ async function setOnline(article: Article, makeOnline: boolean) {
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' },
)
articles.value = articles.value.map((existing) =>
existing.id === updated.id ? updated : existing,
)
replaceArticle(updated, article)
toast.add(() => ({
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
color: 'success',
@@ -209,7 +352,7 @@ async function setOnline(article: Article, makeOnline: boolean) {
}))
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id)
removeArticle(article)
toast.add(() => notFoundToast(article))
return
}
@@ -238,22 +381,26 @@ async function setOnline(article: Article, makeOnline: boolean) {
}
}
function findItem(id: number) {
return items.value.find((existing) => existing.id === id)
}
function handlePublish(id: number) {
const article = articles.value.find((existing) => existing.id === id)
const article = findItem(id)
if (article) {
setOnline(article, true)
}
}
function handleUnpublish(id: number) {
const article = articles.value.find((existing) => existing.id === id)
const article = findItem(id)
if (article) {
setOnline(article, false)
}
}
async function handleCopyUrl(id: number) {
const article = articles.value.find((existing) => existing.id === id)
const article = findItem(id)
if (!article) {
return
}
@@ -295,7 +442,7 @@ function deleteDescription(article: Article) {
async function performDelete(article: Article) {
try {
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
removeArticle(article.id)
removeArticle(article)
toast.add(() => ({
id: `article-deleted-${article.id}`,
color: 'success',
@@ -309,7 +456,7 @@ async function performDelete(article: Article) {
}))
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id)
removeArticle(article)
toast.add(() => notFoundToast(article))
return
}
@@ -331,7 +478,7 @@ async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) {
}
async function handleDelete(article: Article) {
const idsAtOpen = displayedArticles.value.map((existing) => existing.id)
const idsAtOpen = items.value.map((existing) => existing.id)
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
@@ -369,9 +516,9 @@ function onArticleCreated(article: Article) {
}
function clearSearch() {
clearSearchTimer()
searchInput.value = ''
debouncedSearch.value = ''
router.replace({ query: { ...route.query, q: undefined } })
changeSearch('')
nextTick(() => {
document.querySelector<HTMLInputElement>('input[type="search"]')?.focus()
})
@@ -389,7 +536,13 @@ function onEmptyAction() {
openNewArticleModal()
}
onMounted(loadArticles)
onMounted(() => load(shown.value, 'replace'))
onBeforeUnmount(() => {
requestToken++
clearSearchTimer()
clearSkeletonTimer()
})
</script>
<template>
@@ -436,26 +589,59 @@ onMounted(loadArticles)
/>
</div>
<template v-if="!loading || showSkeleton">
<UAlert
v-if="notice"
:role="notice.kind === 'error' ? 'alert' : 'status'"
:color="notice.kind === 'error' ? 'error' : 'info'"
variant="soft"
orientation="horizontal"
:close="false"
:icon="notice.kind === 'error' ? 'i-lucide-circle-x' : 'i-lucide-info'"
:description="noticeText"
:ui="{
root: 'items-center gap-2.5 px-3.5 py-2.5',
icon: 'size-4',
description: 'text-toned',
}"
>
<template v-if="notice.kind === 'error'" #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="retryNotice"
>
{{ t('Retry') }}
</UButton>
</template>
</UAlert>
<template v-if="hasLoaded || loadFailed || showSkeleton">
<EmptyState
v-if="emptyVariant"
:variant="emptyVariant"
:query="debouncedSearch"
:query="shown.q"
@action="onEmptyAction"
/>
<template v-else>
<ArticleTable
:articles="displayedArticles"
:loading="loading && showSkeleton"
:articles="items"
:loading="skeletonActive"
:skeleton-rows="skeletonRows"
:sort="effectiveSort(shown.sort)"
:busy-ids="busyIds"
@sort="changeSort"
@publish="handlePublish"
@unpublish="handleUnpublish"
@delete="handleDelete"
@copy-url="handleCopyUrl"
/>
<ArticleCardList
:articles="displayedArticles"
:loading="loading && showSkeleton"
:articles="items"
:loading="skeletonActive"
:skeleton-rows="skeletonRows"
:busy-ids="busyIds"
@publish="handlePublish"
@unpublish="handleUnpublish"
@@ -464,6 +650,17 @@ onMounted(loadArticles)
/>
</template>
</template>
<AppPaginator
v-if="showPaginator"
:page="shown.page"
:page-count="pageCount"
:total="total"
:size="shown.size"
:loading-page="pendingPage"
@update:page="changePage"
@update:size="changeSize"
/>
</div>
<UButton