feat: SEO SPA Sites, Articles and Integration (4/4)
This commit is contained in:
1 parent
cb737b7ae3
commit
02fb8751fd
47 files changed
+3218
-227
No files matched your search
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user