feat: pagination in the SPA for the Sites and Authors lists

This commit is contained in:
Florian Sylvain committed 2026-10-19 04:00:47 +02:00
1 parent 500ad59458
commit 340a0ad461
34 files changed
+2833 -896

No files matched your search

+66 -218
View File
@@ -1,11 +1,10 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { t, formatNumber } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation'
import AppPaginator from '@/components/AppPaginator.vue'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import AuthorFormPanel from '@/components/AuthorFormPanel.vue'
import AuthorViewPanel from '@/components/AuthorViewPanel.vue'
@@ -14,31 +13,27 @@ import DashboardShell from '@/components/DashboardShell.vue'
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
import DirectoryBadge from '@/components/DirectoryBadge.vue'
import DirectoryList from '@/components/DirectoryList.vue'
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
import DirectoryNoResults from '@/components/DirectoryNoResults.vue'
import DirectoryPageNotice from '@/components/DirectoryPageNotice.vue'
import DirectoryPagination from '@/components/DirectoryPagination.vue'
import LinkUserDialog from '@/components/LinkUserDialog.vue'
import ReassignDialog, { type ReassignOption } from '@/components/ReassignDialog.vue'
import { useAuthors, type Author } from '@/composables/useAuthors'
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
import { useDirectoryListState } from '@/composables/useDirectoryListState'
import { useDirectoryPages } from '@/composables/useDirectoryPages'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useDirectoryView } from '@/composables/useDirectoryView'
import { sameRouteQuery } from '@/lib/articleListQuery'
import { pluralArticles } from '@/lib/directory'
import { copy } from '@/lib/directoryCopy'
import { classifyFailure } from '@/lib/directoryErrors'
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
import {
parseDirectoryQuery,
toDirectoryRouteQuery,
type DirectoryQuery,
} from '@/lib/directoryQuery'
import { pageCountFor } from '@/lib/pagination'
import { useSessionStore } from '@/stores/session'
const SEARCH_DEBOUNCE_MS = 120
const DESKTOP_QUERY = '(min-width: 48rem)'
const LIST_COLUMNS = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px'
const SKELETON_ROWS = 6
const PAGINATOR_THRESHOLD = 25
const INITIAL_SKELETON_ROWS = 6
const NOBODY = 'none'
const LOOKUP_SIZE = 100
@@ -48,51 +43,43 @@ type PanelTarget =
| { kind: 'view'; author: Author }
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const overlay = useLocalizedOverlay()
const toasts = useDirectoryToasts()
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const { listAuthors, listAllAuthors, deleteAuthor } = useAuthors()
const collection = useDirectoryCollection(listAuthors)
const { listAuthors, listAuthorPage, listAllAuthors, deleteAuthor } = useAuthors()
const removal = useDirectoryDelete<Author>()
const { data: result, showSkeleton } = collection
const sessionReady = computed(() => session.status === 'ready')
const list = useDirectoryPages<Author>({
loadPage: listAuthorPage,
ready: () => sessionReady.value,
onLoaded: () => followLink(),
})
const { items, total, shown, notice, searchInput, pageCount } = list
const { target: deleteTarget, pending: deletePending, notice: deleteNotice } = removal
const loadState = computed(() =>
collection.state.value === 'forbidden' ? 'error' : collection.state.value,
)
const pendingPage = ref<number | null>(null)
const searchText = ref(parseDirectoryQuery(route.query).q)
const loadState = computed(() => (list.state.value === 'forbidden' ? 'error' : list.state.value))
const panel = ref<PanelTarget | null>(null)
const linkTarget = ref<Author | null>(null)
const deleteOptions = ref<ReassignOption[]>([])
const destination = ref<string | number | undefined>(undefined)
let searchTimer: ReturnType<typeof setTimeout> | null = null
const sessionReady = computed(() => session.status === 'ready')
const canManage = computed(() => session.canManageContent)
const query = computed(() => parseDirectoryQuery(route.query))
const queryKey = computed(() => JSON.stringify(query.value))
const items = computed(() => result.value?.items ?? [])
const total = computed(() => result.value?.total ?? 0)
const pageCount = computed(() => pageCountFor(total.value, query.value.size))
const firstRun = computed(
() => loadState.value === 'ready' && total.value === 0 && query.value.q === '',
() => loadState.value === 'ready' && total.value === 0 && shown.value.q === '',
)
const noResults = computed(
() => loadState.value === 'ready' && total.value === 0 && query.value.q !== '',
const { noResults, showList, skeletonRows, showPaginator, singlePageLabel } = useDirectoryListState(
{
list,
loadState,
isDesktop,
initialRows: INITIAL_SKELETON_ROWS,
singleLabel: (count) =>
count === 1 ? t('1 author') : t('{0} authors', [formatNumber(count)]),
},
)
const showToolbar = computed(() => loadState.value !== 'error' && !firstRun.value)
const showList = computed(
() =>
(loadState.value === 'ready' && total.value > 0) ||
(loadState.value === 'loading' && showSkeleton.value),
)
const showPaginator = computed(
() => loadState.value === 'ready' && (total.value > PAGINATOR_THRESHOLD || pageCount.value > 1),
)
const overlayOpen = computed(
() => panel.value !== null || linkTarget.value !== null || deleteTarget.value !== null,
)
@@ -113,82 +100,6 @@ const headings = computed(() => [t('Author'), t('Slug'), t('Account'), t('Articl
const ownSlug = computed(() => session.session?.author?.slug ?? '')
const knownSlugs = computed(() => items.value.map((author) => author.slug))
function clearSearchTimer() {
if (searchTimer !== null) {
clearTimeout(searchTimer)
searchTimer = null
}
}
async function load(requested: DirectoryQuery) {
if (result.value !== null) {
pendingPage.value = requested.page
}
const outcome = await collection.load([requested], {
accept: (response) => {
const lastPage = pageCountFor(response.total, requested.size)
if (response.total > 0 && requested.page > lastPage) {
replaceQuery({ ...requested, page: lastPage })
return false
}
return true
},
})
if (outcome !== 'stale') {
pendingPage.value = null
}
if (outcome === 'ready') {
followLink()
}
}
async function replaceQuery(next: DirectoryQuery, mode: 'push' | 'replace' = 'replace') {
const target = toDirectoryRouteQuery(next, route.query)
if (sameRouteQuery(route.query, target)) {
return
}
await router[mode]({ query: target })
}
function normalizeUrl() {
const target = toDirectoryRouteQuery(query.value, route.query)
if (!sameRouteQuery(route.query, target)) {
router.replace({ query: target })
}
}
watch(
[sessionReady, queryKey],
([ready]) => {
if (!ready) {
return
}
normalizeUrl()
load(query.value)
},
{ immediate: true },
)
watch(
() => query.value.q,
(value) => {
if (value !== searchText.value.trim()) {
searchText.value = value
}
},
)
watch(searchText, (value) => {
clearSearchTimer()
searchTimer = setTimeout(() => {
searchTimer = null
const text = value.trim()
if (text !== query.value.q) {
replaceQuery({ q: text, page: 1, size: query.value.size })
}
}, SEARCH_DEBOUNCE_MS)
})
watch(
() => route.query.edit,
() => {
@@ -198,22 +109,6 @@ watch(
},
)
onBeforeUnmount(clearSearchTimer)
function clearSearch() {
clearSearchTimer()
searchText.value = ''
replaceQuery({ q: '', page: 1, size: query.value.size })
}
function changePage(page: number) {
replaceQuery({ ...query.value, page }, 'push')
}
function changeSize(size: number) {
replaceQuery({ ...query.value, size, page: 1 }, 'push')
}
async function findBySlug(slug: string) {
const loaded = items.value.find((author) => author.slug === slug)
if (loaded) {
@@ -267,42 +162,25 @@ function closePanel() {
}
function replaceItem(author: Author) {
if (!result.value) {
return
}
result.value = {
...result.value,
items: result.value.items.map((item) =>
item.id === author.id
? {
...author,
user_email: author.user_email ?? item.user_email,
}
: item,
),
}
items.value = items.value.map((item) =>
item.id === author.id
? {
...author,
user_email: author.user_email ?? item.user_email,
}
: item,
)
}
function removeItem(id: number) {
if (!result.value) {
return
}
const remaining = result.value.items.filter((item) => item.id !== id)
result.value = {
...result.value,
items: remaining,
total: Math.max(0, result.value.total - 1),
}
if (remaining.length === 0 && result.value.total > 0) {
load(query.value)
}
list.removeById(id, (item) => item.id)
}
function onSaved(author: Author) {
if (items.value.some((item) => item.id === author.id)) {
replaceItem(author)
} else {
load(query.value)
list.reload()
}
closePanel()
}
@@ -552,7 +430,7 @@ function menuItems(author: Author): DirectoryMenuItem[] {
return items
}
const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q]))
const noResultsTitle = computed(() => t('No authors match "{0}"', [shown.value.q]))
</script>
<template>
@@ -572,40 +450,26 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q
<UInput
v-if="showToolbar"
v-model="searchText"
v-model="searchInput"
type="search"
icon="i-lucide-search"
:placeholder="t('Search by name or slug')"
:aria-label="t('Search by name or slug')"
class="w-full sm:w-70"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
:ui="{ base: 'h-11 text-lg ps-8.25 sm:h-8 sm:text-sm', leadingIcon: 'size-3.5' }"
/>
<UAlert
<DirectoryPageNotice
v-if="notice?.kind === 'range'"
:notice="notice"
:shown-page="shown.page"
/>
<DirectoryLoadError
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load authors')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<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 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="load(query)"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
@retry="list.retry"
/>
<UEmpty
v-else-if="firstRun"
@@ -629,38 +493,19 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q
</template>
</UEmpty>
<UEmpty
<DirectoryNoResults
v-else-if="noResults"
variant="naked"
icon="i-lucide-search"
:title="noResultsTitle"
:description="t('Check the spelling, or search for a shorter part of the name.')"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-85 text-pretty text-muted',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
@click="clearSearch"
>
{{ t('Clear search') }}
</UButton>
</template>
</UEmpty>
@clear="list.clearSearch"
/>
<DirectoryList
v-else-if="showList"
:items="items"
:columns="LIST_COLUMNS"
:headings="headings"
:loading="loadState === 'loading'"
:skeleton-rows="SKELETON_ROWS"
:loading="list.skeletonActive.value"
:skeleton-rows="skeletonRows"
:read-only="!canManage"
:label="t('Authors')"
:row-title="(author: Author) => author.name"
@@ -697,15 +542,18 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q
</template>
</DirectoryList>
<AppPaginator
v-if="showPaginator"
:page="query.page"
<DirectoryPagination
:notice="notice"
:show-paginator="showPaginator"
:shown-page="shown.page"
:page-count="pageCount"
:total="total"
:size="query.size"
:loading-page="pendingPage"
@update:page="changePage"
@update:size="changeSize"
:size="shown.size"
:loading-page="list.pendingPage.value"
:single-page-label="singlePageLabel"
@retry="list.retry"
@update:page="list.changePage"
@update:size="list.changeSize"
/>
</div>