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:
1 parent
9f4ce426a8
commit
daf3594316
86 files changed
+4223
-1485
No files matched your search
+61
-427
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user