feat: SEO SPA Sites, Articles and Integration (4/4)

This commit is contained in:
Florian Sylvain committed 2026-10-18 15:51:28 +02:00
1 parent cb737b7ae3
commit 02fb8751fd
47 files changed
+3218 -227

No files matched your search

+78 -8
View File
@@ -10,6 +10,7 @@ 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 { useArticleRowActions } from '@/composables/useArticleRowActions'
import {
useArticles,
@@ -20,6 +21,7 @@ import {
import { useSites, type Site } from '@/composables/useSites'
import {
ARTICLE_PAGE_SIZES,
SEO_FILTER,
effectiveSort,
nextSort,
parseArticleQuery,
@@ -30,11 +32,12 @@ import {
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'
const SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120
const DEFAULT_SKELETON_ROWS = 5
const DEFAULT_SKELETON_ROWS = 6
const ARTICLES_PATH = '/article'
type UrlMode = 'push' | 'replace'
@@ -42,13 +45,14 @@ type Notice = { kind: 'range' } | { kind: 'error'; query: ArticleListQuery; mode
const route = useRoute()
const router = useRouter()
const { listArticles } = useArticles()
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) {
@@ -71,6 +75,8 @@ 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)
@@ -135,6 +141,9 @@ const emptyVariant = computed(() => {
if (shown.value.status === 'online') {
return 'no-online' as const
}
if (shown.value.seo) {
return 'needs-attention' as const
}
if (shown.value.site) {
return 'no-site' as const
}
@@ -174,8 +183,37 @@ async function syncUrl(query: ArticleListQuery, mode: UrlMode) {
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
@@ -209,6 +247,11 @@ async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notic
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
@@ -258,6 +301,14 @@ function changeSite(site: string) {
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(
{
@@ -347,6 +398,10 @@ function clearSearch() {
}
function onEmptyAction() {
if (emptyVariant.value === 'needs-attention') {
clearSeoFilter()
return
}
if (emptyVariant.value === 'no-site') {
changeSite('')
return
@@ -383,7 +438,7 @@ onBeforeUnmount(() => {
</script>
<template>
<DashboardShell>
<DashboardShell compact-padding>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
@@ -407,23 +462,38 @@ onBeforeUnmount(() => {
</UButton>
</div>
<div class="flex flex-col-reverse gap-4 sm:flex-row sm:items-end sm:justify-between">
<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"
:ui="{ list: 'gap-5', trigger: 'px-0' }"
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 sm:flex-row sm:items-end sm:gap-4">
<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 sm:w-65"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
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"