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>
|
||||
|
||||
|
||||
+113
-461
@@ -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>
|
||||
@@ -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')
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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] }
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user