fix: technical debts left by the Sites, Authors and SEO slices, plus backend support for the new Sites and Authors pagination

This commit is contained in:
Florian Sylvain committed 2026-10-19 00:25:31 +02:00
1 parent 9f4ce426a8
commit daf3594316
86 files changed
+4223 -1485

No files matched your search

+61 -427
View File
@@ -1,131 +1,52 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import DashboardShell from '@/components/DashboardShell.vue'
import ArticleTable from '@/components/ArticleTable.vue'
import ArticleCardList from '@/components/ArticleCardList.vue'
import ArticleListNotice from '@/components/ArticleListNotice.vue'
import ArticleListToolbar from '@/components/ArticleListToolbar.vue'
import EmptyState from '@/components/EmptyState.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import AppPaginator from '@/components/AppPaginator.vue'
import ArticleSiteFilter from '@/components/ArticleSiteFilter.vue'
import NeedsAttentionToggle from '@/components/NeedsAttentionToggle.vue'
import { useArticleList } from '@/composables/useArticleList'
import { useArticleRowActions } from '@/composables/useArticleRowActions'
import {
useArticles,
type Article,
type ArticleCounts,
type ArticleStatus,
} from '@/composables/useArticles'
import { useSites, type Site } from '@/composables/useSites'
import {
ARTICLE_PAGE_SIZES,
SEO_FILTER,
effectiveSort,
nextSort,
parseArticleQuery,
type ArticleSortField,
sameArticleQuery,
sameRouteQuery,
toRouteQuery,
type ArticleListQuery,
} from '@/lib/articleListQuery'
import { pageCountFor } from '@/lib/pagination'
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
import { ARTICLES_PAGE_SIZE_KEY, readStoredPageSize, storePageSize } from '@/lib/pageSizeMemory'
import { useArticleSites } from '@/composables/useArticleSites'
import type { Article } from '@/composables/useArticles'
import { effectiveSort } from '@/lib/articleListQuery'
const SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120
const DEFAULT_SKELETON_ROWS = 6
const ARTICLES_PATH = '/article'
type UrlMode = 'push' | 'replace'
type Notice = { kind: 'range' } | { kind: 'error'; query: ArticleListQuery; mode: UrlMode }
const route = useRoute()
const router = useRouter()
const { listArticles, countNeedsAttention } = useArticles()
const { listSites } = useSites()
const items = ref<Article[]>([])
const sites = ref<Site[]>([])
const total = ref(0)
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
const needsAttentionCount = ref<number | null>(null)
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
const parsed = parseArticleQuery(query)
if (query.size !== undefined) {
return parsed
}
const stored = readStoredPageSize(ARTICLES_PAGE_SIZE_KEY, ARTICLE_PAGE_SIZES)
return stored === null ? parsed : { ...parsed, size: stored }
}
const shown = ref<ArticleListQuery>(parseWithStoredSize(route.query))
const desired = ref<ArticleListQuery>(shown.value)
const sitesSettled = ref(false)
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 list = useArticleList({
sitesSettled: () => siteList.sitesSettled.value,
usableSite: (query) => siteList.usableSite(query),
})
const {
items,
total,
counts,
shown,
desired,
hasLoaded,
loadFailed,
notice,
showSkeleton,
searchInput,
needsAttentionCount,
settled,
skeletonActive,
showPaginator,
pageCount,
pendingPage,
} = list
const siteList = useArticleSites(() => shown.value.site)
const { sites, severalSites, siteName } = siteList
const newArticleModalOpen = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
let requestToken = 0
let countToken = 0
let countedFor: string | null = null
let syncedTarget: Record<string, string> | null = null
const searchInput = ref(shown.value.q)
function clearSearchTimer() {
if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
watch(searchInput, (value) => {
clearSearchTimer()
searchDebounceTimer = setTimeout(() => changeSearch(value), SEARCH_DEBOUNCE_MS)
})
const activeTab = computed<'' | ArticleStatus>({
get: () => desired.value.status,
set: (value) => changeStatus(value),
})
const tabItems = computed(() => [
{
get label() {
return t('All {0}', [counts.value.all])
},
value: '',
},
{
get label() {
return t('Online {0}', [counts.value.online])
},
value: 'online',
},
{
get label() {
return t('Drafts {0}', [counts.value.draft])
},
value: 'draft',
},
])
const severalSites = computed(() => sites.value.length > 1)
const siteName = computed(
() => sites.value.find((site) => site.key === shown.value.site)?.name ?? shown.value.site,
)
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(() => {
@@ -150,234 +71,13 @@ const emptyVariant = computed(() => {
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() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function syncUrl(query: ArticleListQuery, mode: UrlMode) {
if (route.path !== ARTICLES_PATH) {
return
}
const target = toRouteQuery(query)
if (sameRouteQuery(route.query, target)) {
return
}
syncedTarget = target
await router[mode]({ query: target })
}
function countKey(query: ArticleListQuery) {
return JSON.stringify([query.q, query.status, query.site])
}
async function refreshNeedsAttentionCount(query: ArticleListQuery) {
const key = countKey(query)
if (query.seo || key === countedFor) {
return
}
const token = ++countToken
countedFor = key
needsAttentionCount.value = null
try {
const result = await countNeedsAttention({
q: query.q,
status: query.status,
site: query.site,
})
if (token === countToken) {
needsAttentionCount.value = result
}
} catch {
if (token === countToken) {
countedFor = null
}
}
}
async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notice | null = null) {
const token = ++requestToken
refreshNeedsAttentionCount(query)
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)
try {
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
if (effective.seo) {
countToken++
countedFor = countKey(effective)
needsAttentionCount.value = response.total
}
shown.value = effective
desired.value = effective
hasLoaded.value = true
loadFailed.value = false
notice.value = successNotice
await syncUrl(effective, mode)
} catch {
if (token !== requestToken) {
return
}
if (hasLoaded.value) {
notice.value = { kind: 'error', query, mode }
} else {
items.value = []
loadFailed.value = true
}
} finally {
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) {
storePageSize(ARTICLES_PAGE_SIZE_KEY, size)
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 changeSite(site: string) {
if (site === desired.value.site) {
return
}
load({ ...desired.value, site, page: 1 }, 'push')
}
function changeSeo(on: boolean) {
load({ ...desired.value, seo: on ? SEO_FILTER : '', page: 1 }, 'push')
}
function clearSeoFilter() {
changeSeo(false)
}
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')
}
async function fetchSites() {
try {
sites.value = await listSites()
} catch {
sites.value = []
}
}
function usableSite(query: ArticleListQuery): ArticleListQuery {
const known = severalSites.value && sites.value.some((site) => site.key === query.site)
return query.site && !known ? { ...query, site: '' } : query
}
function retryNotice() {
if (notice.value?.kind === 'error') {
load(notice.value.query, notice.value.mode)
}
}
watch(
() => route.query,
() => {
if (route.path !== ARTICLES_PATH) {
return
}
const ownReplace = syncedTarget !== null && sameRouteQuery(route.query, syncedTarget)
syncedTarget = null
const parsed = ownReplace
? parseArticleQuery(route.query)
: parseWithStoredSize(route.query)
const next = sitesSettled.value ? usableSite(parsed) : parsed
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')
},
)
const { busyIds, handlePublish, handleUnpublish, handleCopyUrl, handleDelete } =
useArticleRowActions({
items,
total,
counts,
shown,
reload: load,
reload: list.load,
})
function openNewArticleModal() {
@@ -389,9 +89,7 @@ function onArticleCreated(article: Article) {
}
function clearSearch() {
clearSearchTimer()
searchInput.value = ''
changeSearch('')
list.clearSearch()
nextTick(() => {
document.querySelector<HTMLInputElement>('input[type="search"]')?.focus()
})
@@ -399,11 +97,11 @@ function clearSearch() {
function onEmptyAction() {
if (emptyVariant.value === 'needs-attention') {
clearSeoFilter()
list.changeSeo(false)
return
}
if (emptyVariant.value === 'no-site') {
changeSite('')
list.changeSite('')
return
}
if (emptyVariant.value === 'no-results') {
@@ -411,29 +109,14 @@ function onEmptyAction() {
return
}
if (emptyVariant.value === 'no-online') {
activeTab.value = 'draft'
list.changeStatus('draft')
return
}
openNewArticleModal()
}
onMounted(async () => {
const siteRequest = fetchSites().finally(() => {
sitesSettled.value = true
})
if (shown.value.site) {
await siteRequest
const usable = usableSite(shown.value)
shown.value = usable
desired.value = usable
}
load(shown.value, 'replace')
})
onBeforeUnmount(() => {
requestToken++
clearSearchTimer()
clearSkeletonTimer()
onMounted(() => {
list.start(siteList.loadSites())
})
</script>
@@ -462,76 +145,27 @@ onBeforeUnmount(() => {
</UButton>
</div>
<div
class="flex flex-col-reverse gap-3 md:flex-row md:items-end md:gap-4 md:border-b md:border-default"
>
<UTabs
v-model="activeTab"
:items="tabItems"
variant="link"
color="neutral"
:content="false"
class="md:-mb-px md:flex-1"
:ui="{
list: 'gap-5 border-b-0 p-0 max-md:border-b',
trigger: `${UNDERLINE_TAB_TRIGGER} max-md:text-base`,
}"
/>
<div class="flex flex-col gap-3 md:flex-row md:items-end md:gap-4 md:pb-1.5">
<UInput
v-model="searchInput"
type="search"
icon="i-lucide-search"
:placeholder="t('Search by title')"
class="w-full md:w-65"
:ui="{
base: 'h-11 text-lg md:h-8 md:text-sm',
leadingIcon: 'size-4 md:size-3.5',
}"
/>
<NeedsAttentionToggle
:model-value="desired.seo !== ''"
:count="needsAttentionCount"
class="md:-order-1"
@update:model-value="changeSeo"
/>
<ArticleSiteFilter
v-if="severalSites"
:model-value="desired.site"
:sites="sites"
@update:model-value="changeSite"
/>
</div>
</div>
<ArticleListToolbar
v-model:search="searchInput"
:status="desired.status"
:counts="counts"
:needs-attention-count="needsAttentionCount"
:needs-attention="desired.seo !== ''"
:site="desired.site"
:sites="sites"
:several-sites="severalSites"
@update:status="list.changeStatus"
@update:needs-attention="list.changeSeo"
@update:site="list.changeSite"
/>
<UAlert
<ArticleListNotice
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>
:kind="notice.kind"
:failed-page="notice.kind === 'error' ? notice.query.page : shown.page"
:shown-page="shown.page"
@retry="list.retryNotice"
/>
<template v-if="hasLoaded || loadFailed || showSkeleton">
<EmptyState
@@ -548,7 +182,7 @@ onBeforeUnmount(() => {
:sort="effectiveSort(shown.sort)"
:sites="severalSites ? sites : []"
:busy-ids="busyIds"
@sort="changeSort"
@sort="list.changeSort"
@publish="handlePublish"
@unpublish="handleUnpublish"
@delete="handleDelete"
@@ -574,8 +208,8 @@ onBeforeUnmount(() => {
:total="total"
:size="shown.size"
:loading-page="pendingPage"
@update:page="changePage"
@update:size="changeSize"
@update:page="list.changePage"
@update:size="list.changeSize"
/>
</div>