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>
+113 -461
View File
@@ -1,60 +1,57 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { createReusableTemplate } from '@vueuse/core'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import { useRoute, useRouter } from 'vue-router'
import { useLocalizedToast } from '@/i18n/presentation'
import DashboardShell from '@/components/DashboardShell.vue'
import ErrorView from '@/components/ErrorView.vue'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import ArticleEditor from '@/components/ArticleEditor.vue'
import ArticleDetails from '@/components/ArticleDetails.vue'
import MetaSection from '@/components/MetaSection.vue'
import SearchSharingSection from '@/components/SearchSharingSection.vue'
import SeoPanel from '@/components/SeoPanel.vue'
import ApiPreviewDialog from '@/components/ApiPreviewDialog.vue'
import SaveStatus from '@/components/SaveStatus.vue'
import PublishControl from '@/components/PublishControl.vue'
import EditorHeader from '@/components/EditorHeader.vue'
import EditorMobileBar from '@/components/EditorMobileBar.vue'
import EditorSkeleton from '@/components/EditorSkeleton.vue'
import { neutralizeUnsafeLinks } from '@/lib/articleLinkUrl'
import EditorStatusBar from '@/components/EditorStatusBar.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ImageUploader from '@/components/ImageUploader.vue'
import { usePageTitleOverride } from '@/composables/documentTitle'
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
import { ApiError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import { useArticleActions } from '@/composables/useArticleActions'
import { useArticleDraft, type SaveOutcome } from '@/composables/useArticleDraft'
import { useSeoCheck } from '@/composables/useSeoCheck'
import { useArticleLifecycle } from '@/composables/useArticleLifecycle'
import { useEditorFocus } from '@/composables/useEditorFocus'
import { useEditorImages } from '@/composables/useEditorImages'
import { useEditorSeoPanel } from '@/composables/useEditorSeoPanel'
import { useSeoDismissals } from '@/composables/useSeoDismissals'
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useSites, type Site } from '@/composables/useSites'
import { toBylines } from '@/lib/articleMeta'
import { readMetaSectionOpen, storeMetaSectionOpen } from '@/lib/metaSectionMemory'
import { readSeoSectionOpen, storeSeoSectionOpen } from '@/lib/seoSectionMemory'
import { SEO_LIMITS, charCount, seoChanges, seoOf, type ArticleSeo } from '@/lib/articleSeo'
import { SEO_LIMITS, seoOf } from '@/lib/articleSeo'
import { hostOf, slugify } from '@/lib/directory'
import type { DirectoryNotice } from '@/lib/directoryTypes'
import type { SeoCheckBody, SeoFocus, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
import { bodyCounts } from '@/lib/bodyCounts'
import { useCanManageVariants } from '@/composables/useCanManageVariants'
import { bodyHasVariantImage } from '@/lib/variants/blockVariant'
import { useVariantCatalogStore } from '@/stores/variantCatalog'
const SKELETON_DELAY_MS = 150
const FOCUS_RETRIES = 10
const FOCUS_RETRY_MS = 30
type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
const route = useRoute()
const router = useRouter()
const { request } = useApi()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
const directoryToasts = useDirectoryToasts()
const { listSites } = useSites()
const actions = useArticleActions()
const lifecycle = useArticleLifecycle()
const {
editorRef,
imageUploaderOpen,
@@ -85,19 +82,52 @@ const publishPending = ref<string | undefined>(undefined)
const sites = ref<Site[]>([])
const remembered = ref<boolean | null>(readMetaSectionOpen())
const seoRemembered = ref<boolean | null>(readSeoSectionOpen())
const panelOpen = ref(false)
const panelValues = ref<ArticleSeo | null>(null)
const panelFocus = ref<SeoFocus | null>(null)
const panelNotice = ref<DirectoryNotice | null>(null)
const panelSaving = ref(false)
const slugTakenOnSave = ref<string | null>(null)
const canonicalRejected = ref(false)
const apiPreviewOpen = ref(false)
const dismissedCodes = ref<SuggestionCode[]>([])
const dismissingCode = ref<SuggestionCode | null>(null)
let panelSaveFailed = false
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
function markNotFound() {
loadState.value = 'notFound'
}
const { focusBody, focusAuthors } = useEditorFocus({
editorRef,
detailsOpen,
previewOn,
metaRemembered: remembered,
})
const {
check,
panelOpen,
panelValues,
panelFocus,
panelNotice,
panelSaving,
slugTakenOnSave,
canonicalRejected,
openPanel,
closePanel,
updatePanelValues,
onPanelCheck,
goToField,
revealSaveFailure,
saveSeoPanel,
} = useEditorSeoPanel({
articleId,
draft,
save: () => runSave(),
onGone: markNotFound,
focusBody,
focusAuthors,
})
const { dismissedCodes, dismissingCode, dismissSuggestion, restoreDismissed } = useSeoDismissals({
articleId,
check,
onGone: markNotFound,
onUnauthorized: draft.mirrorToStorage,
})
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
@@ -124,7 +154,6 @@ function rememberSeoSection(open: boolean) {
storeSeoSectionOpen(open)
}
const seoInEffect = computed(() => panelValues.value ?? draft.seo.value)
const siteOfArticle = computed(() =>
sites.value.find((site) => site.id === draft.meta.value.siteId),
)
@@ -135,223 +164,11 @@ const address = computed(
() => `${hostOf(siteOfArticle.value?.base_url ?? '')}/${addressSlug.value}`,
)
function checkBody(): SeoCheckBody {
const changes = seoChanges(seoInEffect.value, draft.savedSeo.value)
if (charCount(changes.excerpt ?? '') > SEO_LIMITS.excerpt) {
delete changes.excerpt
}
return {
...changes,
...(draft.bodyEdited.value ? { body: draft.body.value } : {}),
}
}
const check = useSeoCheck({
articleId,
body: checkBody,
onGone: () => {
loadState.value = 'notFound'
},
onUnauthorized: draft.mirrorToStorage,
})
function reportDismissFailure() {
toast.add(() => ({
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't dismiss the suggestion")
},
get description() {
return t('Try again in a moment.')
},
}))
}
async function saveDismissed(next: SuggestionCode[]) {
try {
const updated = await request<Pick<Article, 'seo_dismissed'>>(`/v1/article/${articleId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seo_dismissed: next }),
})
dismissedCodes.value = updated?.seo_dismissed ?? next
return true
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
loadState.value = 'notFound'
} else if (err instanceof ApiError && err.status === 401) {
draft.mirrorToStorage()
} else {
reportDismissFailure()
}
return false
}
}
async function dismissSuggestion(code: SuggestionCode) {
if (dismissingCode.value !== null) {
return
}
dismissingCode.value = code
const saved = await saveDismissed([...new Set([...dismissedCodes.value, code])])
if (saved) {
check.dropSuggestion(code)
check.invalidate()
check.runQuietly()
}
dismissingCode.value = null
}
async function restoreDismissed() {
if (await saveDismissed([])) {
check.invalidate()
check.runFresh()
}
}
function editBody(newBody: string) {
draft.edit(newBody)
check.schedule()
}
function openPanel(focus: SeoFocus | null = null) {
panelFocus.value = focus
panelValues.value = { ...draft.seo.value }
panelNotice.value = null
slugTakenOnSave.value = null
canonicalRejected.value = false
panelOpen.value = true
check.runFresh()
}
function closePanel(discardEdits = true) {
panelOpen.value = false
panelValues.value = null
if (panelSaveFailed) {
panelSaveFailed = false
draft.markDirty()
}
if (discardEdits) {
check.restore()
}
}
function updatePanelValues(values: ArticleSeo) {
panelValues.value = values
}
function onPanelCheck(immediate: boolean) {
if (immediate) {
check.runNow()
return
}
check.schedule()
}
function focusBody() {
detailsOpen.value = false
previewOn.value = false
nextTick(() => editorRef.value?.focus())
}
function authorsFocusTarget() {
const scope = detailsOpen.value
? '[role="dialog"] [data-testid="meta-section"]'
: '[data-testid="article-details"] [data-testid="meta-section"]'
return document.querySelector<HTMLElement>(
`${scope} [data-testid="author-row"] button, ${scope} button[aria-haspopup="listbox"]`,
)
}
function focusWhenRendered(find: () => HTMLElement | null, retries = FOCUS_RETRIES) {
const target = find()
if (target) {
target.focus()
return
}
if (retries > 0) {
setTimeout(() => focusWhenRendered(find, retries - 1), FOCUS_RETRY_MS)
}
}
function focusAuthors() {
remembered.value = true
focusWhenRendered(authorsFocusTarget)
}
function goToField(field: SuggestionField) {
if (field === 'body') {
closePanel()
focusBody()
return
}
if (field === 'author_ids') {
closePanel()
focusAuthors()
return
}
openPanel(field)
}
function showPanelFailure(outcome: SaveOutcome) {
if (outcome === 'slug-taken') {
slugTakenOnSave.value = panelValues.value?.slug ?? draft.seo.value.slug
check.runFresh()
return
}
if (outcome === 'invalid-url') {
canonicalRejected.value = true
return
}
panelSaveFailed = true
panelNotice.value = {
title: t(
"Couldn't save search and sharing. Your changes are still here. Try again in a moment.",
),
description: '',
}
}
function announceSeoSaved() {
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Search and sharing saved')
},
get description() {
return t('Your search and sharing settings are up to date.')
},
}))
}
async function saveSeoPanel() {
const values = panelValues.value
if (!values || panelSaving.value) {
return
}
draft.editSeo(values)
if (draft.state.value !== 'dirty') {
closePanel()
return
}
panelNotice.value = null
panelSaving.value = true
const outcome = await runSave()
panelSaving.value = false
if (outcome === 'saved') {
closePanel(false)
announceSeoSaved()
return
}
if (['not-found', 'site-gone', 'author-gone', 'unauthorized'].includes(outcome)) {
closePanel()
return
}
showPanelFailure(outcome)
}
async function loadArticle() {
loadState.value = 'loading'
showSkeleton.value = false
@@ -384,7 +201,7 @@ async function loadArticle() {
}
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
loadState.value = 'notFound'
markNotFound()
return
}
loadState.value = 'error'
@@ -425,10 +242,7 @@ async function runSave(): Promise<SaveOutcome> {
check.invalidate()
check.runFresh()
}
if (!panelOpen.value && (outcome === 'slug-taken' || outcome === 'invalid-url')) {
openPanel('slug')
showPanelFailure(outcome)
}
revealSaveFailure(outcome)
if (outcome === 'site-gone') {
directoryToasts.siteGone()
sites.value = await fetchSites()
@@ -466,27 +280,26 @@ function goBack() {
router.push('/article')
}
async function setOnline(makeOnline: boolean) {
if (!article.value) {
return
}
publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…')
try {
if (makeOnline && draft.state.value === 'dirty' && (await runSave()) !== 'saved') {
return
}
await actions.publish(article.value, makeOnline, {
onUpdated: (updated) => {
article.value = updated
},
onGone: () => {
loadState.value = 'notFound'
},
retry: () => setOnline(makeOnline),
})
} finally {
publishPending.value = undefined
function setOnline(makeOnline: boolean) {
const current = article.value
if (!current) {
return Promise.resolve()
}
return lifecycle.changeOnline(current, makeOnline, {
prepare: async () =>
!(makeOnline && draft.state.value === 'dirty') || (await runSave()) === 'saved',
pending: (busy) => {
publishPending.value = busy
? makeOnline
? t('Publishing…')
: t('Unpublishing…')
: undefined
},
onUpdated: (updated) => {
article.value = updated
},
onGone: markNotFound,
})
}
function handlePublish() {
@@ -502,14 +315,10 @@ async function handleDelete() {
if (!current) {
return
}
await actions.confirmDelete(current, () =>
actions.deleteArticle(current, {
onDeleted: () => router.push('/article'),
onGone: () => {
loadState.value = 'notFound'
},
}),
)
await lifecycle.confirmAndDelete(current, {
onDeleted: () => router.push('/article'),
onGone: markNotFound,
})
}
function onKeydown(event: KeyboardEvent) {
@@ -529,37 +338,9 @@ onBeforeUnmount(() => {
clearSkeletonTimer()
})
onBeforeRouteLeave(async () => {
if (authRedirectPending.value || draft.state.value !== 'dirty') {
return true
}
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Leave without saving?')
},
get description() {
return t("Your changes to \u0022{0}\u0022 haven't been saved.", [
article.value?.title ?? '',
])
},
get confirmLabel() {
return t('Keep editing')
},
get cancelLabel() {
return t('Discard changes')
},
get pendingLabel() {
return t('Keep editing')
},
tone: 'neutral',
action: async () => {},
dismissResolvesTo: 'confirm',
},
})
.open()
return !confirmed
useUnsavedChangesGuard({
isDirty: () => draft.state.value === 'dirty',
title: () => article.value?.title ?? '',
})
</script>
@@ -633,135 +414,25 @@ onBeforeRouteLeave(async () => {
<ErrorView v-if="loadState === 'notFound'" kind="article-not-found" />
<ErrorView v-else-if="loadState === 'error'" kind="error" @retry="loadArticle" />
<div
v-else-if="loadState === 'loading' && showSkeleton"
aria-busy="true"
class="flex min-h-0 flex-1 flex-col lg:flex-row"
>
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<div
class="flex flex-shrink-0 items-center gap-2.5 border-b border-default bg-default px-6 py-3.5"
>
<UButton
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
:aria-label="t('Back to Articles')"
@click="goBack"
/>
<USkeleton class="h-5 w-80 rounded-none" data-testid="skeleton-title" />
<USkeleton class="h-5.5 w-15 rounded-none" />
</div>
<div
class="min-h-0 max-w-168 flex-1 space-y-3.5 px-12 py-10"
data-testid="skeleton-content"
>
<USkeleton class="h-3.5 w-full rounded-none" />
<USkeleton class="h-3.5 w-11/12 rounded-none" />
<USkeleton class="h-3.5 w-4/5 rounded-none" />
<USkeleton class="mt-4.5 h-5.5 w-2/5 rounded-none" />
<USkeleton class="h-3.5 w-full rounded-none" />
<USkeleton class="h-3.5 w-3/4 rounded-none" />
</div>
</div>
<div
class="w-70 flex-shrink-0 space-y-2.5 border-default p-5 lg:border-l"
data-testid="skeleton-details"
>
<div class="flex flex-col gap-3 border-b border-default pb-4">
<div class="flex min-h-6 items-center gap-2 text-highlighted">
<UIcon name="i-lucide-chevron-down" class="size-3.5 -rotate-90" />
<span class="text-sm font-semibold">{{ t('Site and authors') }}</span>
</div>
<USkeleton class="ml-5.5 h-3 w-2/5 rounded-none" data-testid="skeleton-meta" />
</div>
<div class="flex flex-col gap-3 border-b border-default pb-4">
<div class="flex min-h-6 items-center gap-2 text-highlighted">
<UIcon name="i-lucide-chevron-down" class="size-3.5 -rotate-90" />
<span class="text-sm font-semibold">{{ t('Search and sharing') }}</span>
</div>
<USkeleton class="ml-5.5 h-3 w-2/5 rounded-none" data-testid="skeleton-seo" />
</div>
<USkeleton class="h-3 w-full rounded-none" />
<USkeleton class="h-3 w-full rounded-none" />
<USkeleton class="h-3 w-3/5 rounded-none" />
</div>
</div>
<EditorSkeleton v-else-if="loadState === 'loading' && showSkeleton" @back="goBack" />
<div v-else-if="loadState === 'loaded' && article" class="flex min-h-0 flex-1 flex-col">
<div
class="flex flex-shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-default bg-default px-4 py-3 lg:gap-3 lg:px-6 lg:py-3.5"
>
<UButton
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="hidden size-8 justify-center text-toned lg:inline-flex"
:aria-label="t('Back to Articles')"
@click="goBack"
/>
<div
class="flex min-w-0 flex-1 basis-full flex-wrap items-center gap-2.5 lg:min-w-50 lg:basis-50"
>
<h1
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
:title="t('The title can\'t be changed after creation.')"
>
{{ article.title }}
</h1>
<ArticleStatusBadge :online="article.is_online" size="lg" />
</div>
<SaveStatus
:state="draft.state.value"
:saved-at="draft.savedAt.value"
@retry="retrySave"
/>
<span class="ml-auto font-mono text-xs text-muted lg:hidden">{{ countText }}</span>
<div class="hidden items-center gap-3 lg:flex">
<USwitch
v-model="previewOn"
:label="t('Preview')"
:ui="{
root: 'items-center gap-2',
container: 'h-auto',
base: 'h-4.5 w-8 p-0.5',
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-2.5',
wrapper: 'ms-0',
label: 'text-sm font-normal text-toned',
}"
/>
<UButton
data-testid="save-button"
icon="i-lucide-save"
color="neutral"
variant="outline"
class="h-8 gap-1.5 px-3"
:disabled="!saveEnabled"
@click="performSave"
>
{{ t('Save') }}
</UButton>
<PublishControl
class="h-8 gap-1.5 px-3"
:online="article.is_online"
:dirty="draft.state.value === 'dirty'"
:pending="publishPending"
:disabled="publishDisabled"
@publish="handlePublish"
@unpublish="handleUnpublish"
/>
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
class="h-8 gap-1.5 px-3"
@click="handleDelete"
>
{{ t('Delete') }}
</UButton>
</div>
</div>
<EditorHeader
v-model:preview="previewOn"
:article="article"
:state="draft.state.value"
:saved-at="draft.savedAt.value"
:count-text="countText"
:save-enabled="saveEnabled"
:publish-pending="publishPending"
:publish-disabled="publishDisabled"
@back="goBack"
@retry="retrySave"
@save="performSave"
@publish="handlePublish"
@unpublish="handleUnpublish"
@delete="handleDelete"
/>
<div v-if="previewOn" class="flex min-h-0 flex-1">
<div
@@ -849,36 +520,17 @@ onBeforeRouteLeave(async () => {
@close="onImageUploaderClosed"
/>
<div class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden">
<UButton
icon="i-lucide-panel-right"
color="neutral"
variant="outline"
class="size-11 justify-center"
:aria-label="t('Details')"
@click="detailsOpen = true"
/>
<UButton
data-testid="save-button-mobile"
color="neutral"
variant="outline"
class="h-11 flex-1 justify-center"
:disabled="!saveEnabled"
@click="performSave"
>
{{ t('Save') }}
</UButton>
<PublishControl
class="h-11 flex-1 justify-center"
hide-icon
:online="article.is_online"
:dirty="draft.state.value === 'dirty'"
:pending="publishPending"
:disabled="publishDisabled"
@publish="handlePublish"
@unpublish="handleUnpublish"
/>
</div>
<EditorMobileBar
:online="article.is_online"
:state="draft.state.value"
:save-enabled="saveEnabled"
:publish-pending="publishPending"
:publish-disabled="publishDisabled"
@details="detailsOpen = true"
@save="performSave"
@publish="handlePublish"
@unpublish="handleUnpublish"
/>
</div>
</DashboardShell>
</template>
+1 -7
View File
@@ -7,12 +7,7 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import {
NEEDS_ATTENTION_ROUTE,
articleEnvelope,
articleListRoute,
needsAttentionCountPage,
} from './articleEnvelope'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
const NOW = new Date('2026-08-26T10:00:00Z')
const HOUR_MS = 60 * 60 * 1000
@@ -145,7 +140,6 @@ async function mountArticles(handlers: Record<string, () => Response | Promise<R
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter({
[NEEDS_ATTENTION_ROUTE]: () => mockResponse(200, needsAttentionCountPage()),
...handlers,
}) as unknown as typeof fetch
await router.push('/article')
@@ -8,13 +8,7 @@ import type { Article } from '@/composables/useArticles'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import {
articleEnvelope,
isNeedsAttentionCount,
needsAttentionCountPage,
parseListUrl,
type EnvelopeParams,
} from './articleEnvelope'
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
const KEY = 'renewcms-page-size-articles'
const TOTAL = 312
@@ -51,9 +45,6 @@ function installFetch() {
json: async () => oneSite,
} as unknown as Response)
}
if (method === 'GET' && isNeedsAttentionCount(url)) {
return Promise.resolve(mockResponse(200, needsAttentionCountPage()))
}
if (method === 'GET' && url.startsWith('/v1/article/?')) {
const params = parseListUrl(url)
listCalls.push(params)
@@ -9,13 +9,7 @@ import type { Article, ArticlePage } from '@/composables/useArticles'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import {
articleEnvelope,
isNeedsAttentionCount,
needsAttentionCountPage,
parseListUrl,
type EnvelopeParams,
} from './articleEnvelope'
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
const TOTAL = 312
const PLASTER_EVERY = 20
@@ -91,9 +85,6 @@ function installFetch() {
if (method === 'GET' && url === '/v1/site/') {
return Promise.resolve(mockResponse(200, oneSite))
}
if (method === 'GET' && isNeedsAttentionCount(url)) {
return Promise.resolve(mockResponse(200, needsAttentionCountPage()))
}
if (method === 'GET' && url.startsWith('/v1/article/?')) {
const params = parseListUrl(url)
listCalls.push(params)
@@ -178,7 +178,7 @@ describe('Default, every card collapsed', () => {
['articles', 'seo', 'media', 'users', 'settings'].map(
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
),
).toEqual([8, 2, 10, 6, 1])
).toEqual([8, 2, 11, 6, 1])
expect(section(wrapper, 'articles').text()).toContain('8 routes')
expect(section(wrapper, 'seo').text()).toContain('2 routes')
expect(section(wrapper, 'sites').text()).toContain('1 route')
+54 -50
View File
@@ -309,87 +309,91 @@ describe('Needs attention toggle', () => {
})
describe('Needs attention count', () => {
it('reads the total of the extra request with the current q, status and site', async () => {
function envelope(counts: Record<string, number>) {
return { items: [], total: 0, page: 1, size: 25, counts }
}
it('reads counts.suggestions of the list response and sends no extra request', async () => {
const backend = backendWith(boardArticles(), 'several')
await mountMeta('articles', '/article?q=Article&status=online&site=renew', { backend })
const calls = countCalls(backend)
expect(calls).toHaveLength(1)
expect(calls[0]!.raw).toBe(
'/v1/article/?seo=suggestions&page=1&size=5&q=Article&status=online&site=renew',
)
expect(textOf(toggle())).toBe('Needs attention 3')
expect(
callsTo(backend, 'GET', '/v1/article/').filter(
(call) => !call.query.has('format') || call.query.get('size') === '5',
),
).toHaveLength(0)
})
it.each([
{ total: 3, label: 'Needs attention 3' },
{ total: 0, label: 'Needs attention 0' },
])('shows $label', async ({ total, label }) => {
{ suggestions: 3, label: 'Needs attention 3' },
{ suggestions: 0, label: 'Needs attention 0' },
])('shows $label', async ({ suggestions, label }) => {
const backend = backendWith(boardArticles())
backend.responders['GET /v1/article/'] = (call) =>
call.query.has('format')
? {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 0, online: 0, draft: 0 },
}
: { items: [], total, page: 1, size: 5, counts: { all: 0, online: 0, draft: 0 } }
backend.responders['GET /v1/article/'] = () =>
envelope({ all: 5, online: 2, draft: 3, suggestions })
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe(label)
})
it.each([
{ failure: 'status 500', responder: failWith(500) },
{ failure: 'a network error', responder: networkFailure },
])('shows no count and no notice on $failure', async ({ responder }) => {
it('shows no count when counts.suggestions is absent', async () => {
const backend = backendWith(boardArticles())
const defaults = backend.responders
backend.responders['GET /v1/article/'] = (call) => {
if (!call.query.has('format')) {
return responder(call)
}
return defaults
? {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 5, online: 2, draft: 3 },
}
: undefined
}
backend.responders['GET /v1/article/'] = () => envelope({ all: 5, online: 2, draft: 3 })
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention')
})
it('shows no count before the first response arrives', async () => {
const backend = backendWith(boardArticles())
backend.responders['GET /v1/article/'] = () => new Promise(() => {})
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention')
})
it.each([
{ failure: 'status 500', responder: failWith(500) },
{ failure: 'a network error', responder: networkFailure },
])('shows no count and no extra notice on $failure', async ({ responder }) => {
const backend = backendWith(boardArticles())
backend.responders['GET /v1/article/'] = responder
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention')
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(currentQuery()).toEqual({})
})
it('refreshes when q, tab or site change and never for page, size or sort', async () => {
it('keeps the last count while a new request is in flight', async () => {
const backend = backendWith(boardArticles(), 'several')
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention 3')
backend.responders['GET /v1/article/'] = () => new Promise(() => {})
await clickTab('Drafts')
expect(textOf(toggle())).toBe('Needs attention 3')
})
it('does not change when q, tab, site, sort, page or size change', async () => {
const backend = backendWith(
Array.from({ length: 30 }, (_, index) => withSeo(index + 1, 'suggestions', 1)),
'several',
)
await mountMeta('articles', '/article?size=5', { backend })
expect(countCalls(backend)).toHaveLength(1)
expect(textOf(toggle())).toBe('Needs attention 30')
document.body.querySelector<HTMLElement>('button[aria-label="Next page"]')!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('thead button'))
.find((item) => textOf(item).startsWith('Title'))!
.click()
await flushPromises()
expect(countCalls(backend)).toHaveLength(1)
await clickTab('Drafts')
expect(countCalls(backend)).toHaveLength(2)
await search('Article 1')
expect(countCalls(backend)).toHaveLength(3)
expect(textOf(toggle())).toBe('Needs attention 30')
expect(countCalls(backend)).toHaveLength(0)
})
it('uses the main response total while the filter is on, with no extra request', async () => {
it('reads counts.suggestions while the filter is on, with no extra request', async () => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article?seo=suggestions', { backend })
@@ -183,14 +183,6 @@ export function stripCards() {
)
}
export async function setSearch(value: string) {
const input = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
return input
}
export function lastListQuery() {
const queries = callsTo(LIST_PATH)
return queries[queries.length - 1]
@@ -243,11 +235,3 @@ export function dialogButton(label: string) {
(button) => textOf(button) === label || button.getAttribute('aria-label') === label,
)
}
export function dialogButtons() {
return Array.from(
dialogElement()?.querySelectorAll<HTMLButtonElement>(
'footer button, [data-slot="footer"] button',
) ?? [],
).map(textOf)
}
@@ -10,9 +10,6 @@ export interface EnvelopeParams {
seo?: '' | 'suggestions'
}
export const NEEDS_ATTENTION_TOTAL = 3
export const NEEDS_ATTENTION_ROUTE = 'GET /v1/article/?seo=suggestions&page=1&size=5'
function fold(value: string) {
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
@@ -45,15 +42,6 @@ export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): Ar
}
}
export function isNeedsAttentionCount(url: string) {
const query = new URL(url, 'http://localhost').searchParams
return query.get('seo') === 'suggestions' && query.get('size') === '5' && !query.has('format')
}
export function needsAttentionCountPage(total = NEEDS_ATTENTION_TOTAL): ArticlePage {
return { items: [], total, page: 1, size: 5, counts: { all: 0, online: 0, draft: 0 } }
}
export function articleListUrl(params: EnvelopeParams = {}) {
const { page = 1, size = 25, q = '', status = '', sort = '', site = '', seo = '' } = params
const query = new URLSearchParams({
-5
View File
@@ -58,11 +58,6 @@ export const camille: Byline = {
slug: 'camille-roche',
}
export const paul: Byline = { id: 8, name: 'Paul Huber', slug: 'paul-huber' }
export const leaFontaine: Byline = {
id: 10,
name: 'Léa Fontaine',
slug: 'lea-fontaine',
}
export const articleMeta = { site_id: defaultSite.id, authors: [ines] }
+4
View File
@@ -189,6 +189,10 @@ function listArticles(backend: MetaBackend, query: URLSearchParams) {
all: backend.articles.length,
online,
draft: backend.articles.length - online,
suggestions: backend.articles.filter(
(article) =>
article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0,
).length,
},
}
}
+3 -6
View File
@@ -112,6 +112,9 @@ export function seoBackend(
size: 100,
counts: { all: 2, unused: 0, duplicates: 0 },
})
for (const item of [NORTH_WALL, KITCHEN_AFTER]) {
backend.responders[`GET /v1/media/${item.id}`] = () => item
}
return backend
}
@@ -196,12 +199,6 @@ export async function openDisclosure(title: string) {
await flushPromises()
}
export function inputByPlaceholder(root: ParentNode, placeholder: string) {
return root.querySelector<HTMLInputElement | HTMLTextAreaElement>(
`[placeholder="${placeholder}"]`,
)!
}
export function fieldByLabel(root: ParentNode, label: string) {
const element = Array.from(root.querySelectorAll('label')).find((candidate) =>
textOf(candidate).startsWith(label),