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
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: 'range' | 'error'
|
||||
failedPage: number
|
||||
shownPage: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
const isError = computed(() => props.kind === 'error')
|
||||
|
||||
const text = computed(() => {
|
||||
if (props.kind === 'range') {
|
||||
return t("That page doesn't exist. Showing the last page.")
|
||||
}
|
||||
return t("Couldn't load page {0}. Showing page {1}.", [props.failedPage, props.shownPage])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAlert
|
||||
:role="isError ? 'alert' : 'status'"
|
||||
:color="isError ? 'error' : 'info'"
|
||||
variant="soft"
|
||||
orientation="horizontal"
|
||||
:close="false"
|
||||
:icon="isError ? 'i-lucide-circle-x' : 'i-lucide-info'"
|
||||
:description="text"
|
||||
:ui="{
|
||||
root: 'items-center gap-2.5 px-3.5 py-2.5',
|
||||
icon: 'size-4',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
>
|
||||
<template v-if="isError" #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="$emit('retry')"
|
||||
>
|
||||
{{ t('Retry') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import ArticleSiteFilter from '@/components/ArticleSiteFilter.vue'
|
||||
import NeedsAttentionToggle from '@/components/NeedsAttentionToggle.vue'
|
||||
import type { ArticleCounts, ArticleStatus } from '@/composables/useArticles'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
|
||||
|
||||
const props = defineProps<{
|
||||
status: '' | ArticleStatus
|
||||
counts: ArticleCounts
|
||||
needsAttentionCount: number | null
|
||||
needsAttention: boolean
|
||||
site: string
|
||||
sites: Site[]
|
||||
severalSites: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:status': [status: '' | ArticleStatus]
|
||||
'update:needsAttention': [on: boolean]
|
||||
'update:site': [site: string]
|
||||
}>()
|
||||
|
||||
const search = defineModel<string>('search', { required: true })
|
||||
|
||||
const activeTab = computed<'' | ArticleStatus>({
|
||||
get: () => props.status,
|
||||
set: (value) => emit('update:status', value),
|
||||
})
|
||||
|
||||
const tabItems = computed(() => [
|
||||
{
|
||||
get label() {
|
||||
return t('All {0}', [props.counts.all])
|
||||
},
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Online {0}', [props.counts.online])
|
||||
},
|
||||
value: 'online',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Drafts {0}', [props.counts.draft])
|
||||
},
|
||||
value: 'draft',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="search"
|
||||
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="needsAttention"
|
||||
:count="needsAttentionCount"
|
||||
class="md:-order-1"
|
||||
@update:model-value="emit('update:needsAttention', $event)"
|
||||
/>
|
||||
<ArticleSiteFilter
|
||||
v-if="severalSites"
|
||||
:model-value="site"
|
||||
:sites="sites"
|
||||
@update:model-value="emit('update:site', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import PublishControl from '@/components/PublishControl.vue'
|
||||
import SaveStatus from '@/components/SaveStatus.vue'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import type { ArticleDraftState } from '@/composables/useArticleDraft'
|
||||
|
||||
defineProps<{
|
||||
article: Article
|
||||
state: ArticleDraftState
|
||||
savedAt: Date | null
|
||||
countText: string
|
||||
saveEnabled: boolean
|
||||
publishPending: string | undefined
|
||||
publishDisabled: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
retry: []
|
||||
save: []
|
||||
publish: []
|
||||
unpublish: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const previewOn = defineModel<boolean>('preview', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="emit('back')"
|
||||
/>
|
||||
<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="state" :saved-at="savedAt" @retry="emit('retry')" />
|
||||
<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="emit('save')"
|
||||
>
|
||||
{{ t('Save') }}
|
||||
</UButton>
|
||||
<PublishControl
|
||||
class="h-8 gap-1.5 px-3"
|
||||
:online="article.is_online"
|
||||
:dirty="state === 'dirty'"
|
||||
:pending="publishPending"
|
||||
:disabled="publishDisabled"
|
||||
@publish="emit('publish')"
|
||||
@unpublish="emit('unpublish')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
class="h-8 gap-1.5 px-3"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
{{ t('Delete') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import PublishControl from '@/components/PublishControl.vue'
|
||||
import type { ArticleDraftState } from '@/composables/useArticleDraft'
|
||||
|
||||
defineProps<{
|
||||
online: boolean
|
||||
state: ArticleDraftState
|
||||
saveEnabled: boolean
|
||||
publishPending: string | undefined
|
||||
publishDisabled: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
details: []
|
||||
save: []
|
||||
publish: []
|
||||
unpublish: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="emit('details')"
|
||||
/>
|
||||
<UButton
|
||||
data-testid="save-button-mobile"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 flex-1 justify-center"
|
||||
:disabled="!saveEnabled"
|
||||
@click="emit('save')"
|
||||
>
|
||||
{{ t('Save') }}
|
||||
</UButton>
|
||||
<PublishControl
|
||||
class="h-11 flex-1 justify-center"
|
||||
hide-icon
|
||||
:online="online"
|
||||
:dirty="state === 'dirty'"
|
||||
:pending="publishPending"
|
||||
:disabled="publishDisabled"
|
||||
@publish="emit('publish')"
|
||||
@unpublish="emit('unpublish')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div 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="emit('back')"
|
||||
/>
|
||||
<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>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import FallbackChip from '@/components/FallbackChip.vue'
|
||||
import SeoDisclosure from '@/components/SeoDisclosure.vue'
|
||||
import { SEO_LIMITS } from '@/lib/articleSeo'
|
||||
import { chipLabel } from '@/lib/seoCopy'
|
||||
import { monoFieldUi } from '@/lib/seoFieldUi'
|
||||
|
||||
defineProps<{
|
||||
canonicalUrl: string
|
||||
placeholder: string
|
||||
summary: string
|
||||
error: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
type: [value: string]
|
||||
leave: []
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SeoDisclosure v-model:open="open" :title="t('Advanced')" :summary="summary">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField
|
||||
:label="t('Canonical URL')"
|
||||
optional
|
||||
:error="error"
|
||||
:help="t('Only change this if the same content lives at another address.')"
|
||||
>
|
||||
<UInput
|
||||
:model-value="canonicalUrl"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="SEO_LIMITS.canonical_url"
|
||||
:color="error ? 'error' : undefined"
|
||||
:aria-invalid="error ? 'true' : undefined"
|
||||
inputmode="url"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="monoFieldUi"
|
||||
@update:model-value="emit('type', String($event))"
|
||||
@focusout="emit('leave')"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip v-if="!canonicalUrl" :label="chipLabel('canonical')" />
|
||||
</div>
|
||||
</SeoDisclosure>
|
||||
</template>
|
||||
+75
-267
@@ -1,41 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { createReusableTemplate, useMediaQuery } from '@vueuse/core'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import DirectoryPanel from '@/components/DirectoryPanel.vue'
|
||||
import FallbackChip from '@/components/FallbackChip.vue'
|
||||
import MediaField from '@/components/MediaField.vue'
|
||||
import SearchPreview from '@/components/SearchPreview.vue'
|
||||
import SeoDisclosure from '@/components/SeoDisclosure.vue'
|
||||
import SeoAdvancedSection from '@/components/SeoAdvancedSection.vue'
|
||||
import SeoSocialSection from '@/components/SeoSocialSection.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import SocialCardPreview from '@/components/SocialCardPreview.vue'
|
||||
import SoftCounter from '@/components/SoftCounter.vue'
|
||||
import SuggestionList from '@/components/SuggestionList.vue'
|
||||
import type { SeoCheckPhase } from '@/composables/useSeoCheck'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { useMediaNames } from '@/composables/useMediaNames'
|
||||
import { useSeoPanelCanonical } from '@/composables/useSeoPanelCanonical'
|
||||
import { useSeoPanelImages } from '@/composables/useSeoPanelImages'
|
||||
import { useSeoPanelSlug } from '@/composables/useSeoPanelSlug'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import {
|
||||
SEO_LIMITS,
|
||||
canonicalIsValid,
|
||||
charCount,
|
||||
normalizeSlug,
|
||||
typedSlug,
|
||||
type ArticleSeo,
|
||||
} from '@/lib/articleSeo'
|
||||
import { slugify } from '@/lib/directory'
|
||||
import { SEO_LIMITS, charCount, type ArticleSeo } from '@/lib/articleSeo'
|
||||
import { chipLabel, descriptionChip } from '@/lib/seoCopy'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
import { areaUi, fieldUi } from '@/lib/seoFieldUi'
|
||||
import {
|
||||
DESCRIPTION_RANGE,
|
||||
TITLE_RANGE,
|
||||
hasSocialOverrides,
|
||||
siteDisplayName,
|
||||
previewDescription,
|
||||
previewDescriptionSource,
|
||||
socialImageSource,
|
||||
type PreviewDevice,
|
||||
type SeoPreviewInput,
|
||||
} from '@/lib/seoPreview'
|
||||
@@ -86,18 +79,9 @@ const [DefinePreviews, Previews] = createReusableTemplate()
|
||||
const [DefinePreviewButton, PreviewButton] = createReusableTemplate()
|
||||
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const names = useMediaNames()
|
||||
const layout = ref<HTMLElement | null>(null)
|
||||
const focusTarget = ref<SeoFocus | null>(props.focus)
|
||||
const device = ref<PreviewDevice>('desktop')
|
||||
const socialOpen = ref(false)
|
||||
const advancedOpen = ref(props.canonicalRejected)
|
||||
const canonicalMessage = ref('')
|
||||
const generatedSlug = computed(() => slugify(props.articleTitle, SEO_LIMITS.slug))
|
||||
const slugManual = ref(
|
||||
props.modelValue.slug !== '' && props.modelValue.slug !== generatedSlug.value,
|
||||
)
|
||||
const slugText = ref(props.modelValue.slug || generatedSlug.value)
|
||||
|
||||
const seo = computed(() => props.modelValue)
|
||||
const siteName = computed(() => siteDisplayName(props.site))
|
||||
@@ -107,66 +91,53 @@ const previewInput = computed<SeoPreviewInput>(() => ({
|
||||
body: props.body,
|
||||
site: props.site,
|
||||
}))
|
||||
const displaySlug = computed(() => (slugManual.value ? slugText.value : generatedSlug.value))
|
||||
const slugMode = computed(() => {
|
||||
if (slugManual.value) {
|
||||
return 'manual' as const
|
||||
}
|
||||
return generatedSlug.value ? ('auto' as const) : ('empty' as const)
|
||||
})
|
||||
const siteImageId = computed(() => props.site?.default_social_image_id ?? null)
|
||||
const siteImageName = computed(() => {
|
||||
const id = siteImageId.value
|
||||
if (id === null) {
|
||||
return ''
|
||||
}
|
||||
return names.itemOf(id)?.filename ?? t('Image #{0}', [id])
|
||||
})
|
||||
const coverItem = computed(() => names.itemOf(seo.value.cover_image_id))
|
||||
const socialItem = computed(() => names.itemOf(seo.value.social_image_id))
|
||||
const cardSource = computed(() => socialImageSource(seo.value, siteImageId.value !== null))
|
||||
const cardImageItem = computed(() => {
|
||||
const items = {
|
||||
social: socialItem.value,
|
||||
cover: coverItem.value,
|
||||
site: names.itemOf(siteImageId.value),
|
||||
none: null,
|
||||
}
|
||||
return items[cardSource.value]
|
||||
})
|
||||
const cardImageName = computed(() => {
|
||||
const labels = {
|
||||
social: socialItem.value?.filename ?? t('Image #{0}', [seo.value.social_image_id ?? 0]),
|
||||
cover: coverItem.value?.filename ?? t('Image #{0}', [seo.value.cover_image_id ?? 0]),
|
||||
site: siteImageName.value,
|
||||
none: '',
|
||||
}
|
||||
return labels[cardSource.value]
|
||||
|
||||
function update(patch: Partial<ArticleSeo>, immediate = false) {
|
||||
emit('update:modelValue', { ...props.modelValue, ...patch })
|
||||
emit('check', immediate)
|
||||
}
|
||||
|
||||
const {
|
||||
siteImageId,
|
||||
siteImageName,
|
||||
coverItem,
|
||||
socialItem,
|
||||
cardSource,
|
||||
cardImageItem,
|
||||
cardImageName,
|
||||
remember,
|
||||
} = useSeoPanelImages(seo, () => props.site)
|
||||
|
||||
const {
|
||||
displaySlug,
|
||||
slugMode,
|
||||
slugSuggestion,
|
||||
slugError,
|
||||
slugUrl,
|
||||
typeSlug,
|
||||
unlockSlug,
|
||||
resetSlug,
|
||||
useSuggestedSlug,
|
||||
checkSlug,
|
||||
} = useSeoPanelSlug({
|
||||
seo,
|
||||
articleTitle: () => props.articleTitle,
|
||||
savedSlug: () => props.savedSlug,
|
||||
site: () => props.site,
|
||||
siteName,
|
||||
slugTakenOnSave: () => props.slugTakenOnSave,
|
||||
slugVerdict: () => props.slugVerdict,
|
||||
checkedSlug: () => props.checkedSlug,
|
||||
update,
|
||||
requestCheck: () => emit('check', true),
|
||||
})
|
||||
|
||||
const slugTaken = computed(() => {
|
||||
if (props.slugTakenOnSave !== null && props.slugTakenOnSave === seo.value.slug) {
|
||||
return true
|
||||
}
|
||||
const verdict = props.slugVerdict
|
||||
return (
|
||||
verdict !== null &&
|
||||
!verdict.available &&
|
||||
props.checkedSlug !== undefined &&
|
||||
props.checkedSlug === seo.value.slug
|
||||
)
|
||||
})
|
||||
const slugSuggestion = computed(() =>
|
||||
slugTaken.value && props.checkedSlug === seo.value.slug
|
||||
? (props.slugVerdict?.suggestion ?? '')
|
||||
: '',
|
||||
)
|
||||
const slugError = computed(() =>
|
||||
slugTaken.value
|
||||
? t('The slug "{0}" is already used on {1}.', [displaySlug.value, siteName.value])
|
||||
: '',
|
||||
)
|
||||
const slugUrl = computed(() => `${props.site?.base_url ?? ''}/${displaySlug.value}`)
|
||||
const { advancedOpen, advancedSummary, canonicalError, typeCanonical, canonicalBlocked } =
|
||||
useSeoPanelCanonical({
|
||||
seo,
|
||||
rejected: () => props.canonicalRejected,
|
||||
update,
|
||||
})
|
||||
|
||||
const descriptionPlaceholder = computed(() => previewDescription(previewInput.value))
|
||||
const descriptionChipLabel = computed(() =>
|
||||
@@ -184,40 +155,11 @@ const coverHelp = computed(() => {
|
||||
? t('Using the site social image ({0}).', [siteImageName.value])
|
||||
: t('Using the site social image.')
|
||||
})
|
||||
const coverPicker = computed(() =>
|
||||
pickerCopy(t('Choose a cover image'), t('The cover for this article.')),
|
||||
)
|
||||
const socialPicker = computed(() =>
|
||||
pickerCopy(t('Choose a social image'), t('The image shown when this article is shared.')),
|
||||
)
|
||||
const customised = computed(() => hasSocialOverrides(seo.value))
|
||||
const socialSummary = computed(() => {
|
||||
if (customised.value) {
|
||||
return t('Customised')
|
||||
}
|
||||
return socialOpen.value ? '' : t('Uses the meta values')
|
||||
})
|
||||
const advancedSummary = computed(() => {
|
||||
if (advancedOpen.value) {
|
||||
return ''
|
||||
}
|
||||
return seo.value.canonical_url ? t('Canonical URL set') : t('Canonical URL: automatic')
|
||||
})
|
||||
const canonicalError = computed(() =>
|
||||
canonicalMessage.value || props.canonicalRejected
|
||||
? t('Enter a full address starting with https://')
|
||||
: '',
|
||||
)
|
||||
const canonicalPlaceholder = computed(() => slugUrl.value)
|
||||
|
||||
function pickerCopy(title: string, description: string) {
|
||||
return { title, description, confirmLabel: t('Use image') }
|
||||
}
|
||||
|
||||
function update(patch: Partial<ArticleSeo>, immediate = false) {
|
||||
emit('update:modelValue', { ...props.modelValue, ...patch })
|
||||
emit('check', immediate)
|
||||
}
|
||||
const coverPicker = computed(() => ({
|
||||
title: t('Choose a cover image'),
|
||||
description: t('The cover for this article.'),
|
||||
confirmLabel: t('Use image'),
|
||||
}))
|
||||
|
||||
function typeText(
|
||||
key:
|
||||
@@ -232,56 +174,6 @@ function typeText(
|
||||
update({ [key]: value })
|
||||
}
|
||||
|
||||
function typeCanonical(value: string) {
|
||||
canonicalMessage.value = ''
|
||||
update({ canonical_url: value })
|
||||
}
|
||||
|
||||
function canonicalBlocked() {
|
||||
if (canonicalIsValid(seo.value.canonical_url)) {
|
||||
return false
|
||||
}
|
||||
canonicalMessage.value = 'invalid'
|
||||
advancedOpen.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
function checkCanonical() {
|
||||
canonicalBlocked()
|
||||
}
|
||||
|
||||
function autoSlug() {
|
||||
return generatedSlug.value === props.savedSlug ? props.savedSlug : ''
|
||||
}
|
||||
|
||||
function unlockSlug() {
|
||||
slugManual.value = true
|
||||
slugText.value = generatedSlug.value
|
||||
}
|
||||
|
||||
function typeSlug(value: string) {
|
||||
slugText.value = typedSlug(value)
|
||||
update({ slug: normalizeSlug(slugText.value) })
|
||||
}
|
||||
|
||||
function resetSlug() {
|
||||
slugManual.value = false
|
||||
slugText.value = generatedSlug.value
|
||||
update({ slug: autoSlug() }, true)
|
||||
}
|
||||
|
||||
function useSuggestedSlug(suggestion: string) {
|
||||
slugManual.value = true
|
||||
slugText.value = suggestion
|
||||
update({ slug: suggestion }, true)
|
||||
}
|
||||
|
||||
function checkSlug() {
|
||||
if (slugManual.value) {
|
||||
emit('check', true)
|
||||
}
|
||||
}
|
||||
|
||||
function setCover(id: number | null) {
|
||||
update({ cover_image_id: id }, true)
|
||||
}
|
||||
@@ -294,12 +186,6 @@ function setNoindex(value: boolean) {
|
||||
update({ noindex: value }, true)
|
||||
}
|
||||
|
||||
function remember(item: MediaItem | null) {
|
||||
if (item) {
|
||||
names.remember(item)
|
||||
}
|
||||
}
|
||||
|
||||
function goTo(field: SuggestionField) {
|
||||
if (!PANEL_FIELDS.includes(field)) {
|
||||
emit('go', field)
|
||||
@@ -317,26 +203,6 @@ function submit() {
|
||||
emit('submit')
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.canonicalRejected,
|
||||
(rejected) => {
|
||||
if (rejected) {
|
||||
advancedOpen.value = true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
names.resolve([siteImageId.value, seo.value.cover_image_id, seo.value.social_image_id])
|
||||
})
|
||||
|
||||
const DISABLED_FIELD = 'disabled:bg-elevated disabled:opacity-100'
|
||||
const fieldUi = { base: `h-11 text-lg text-ellipsis md:h-10 md:text-sm ${DISABLED_FIELD}` }
|
||||
const monoFieldUi = {
|
||||
base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${DISABLED_FIELD}`,
|
||||
}
|
||||
const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD}` }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -552,84 +418,26 @@ const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD
|
||||
</DirectoryField>
|
||||
</div>
|
||||
|
||||
<SeoDisclosure
|
||||
v-model:open="socialOpen"
|
||||
:title="t('Social sharing')"
|
||||
:tag="t('Optional')"
|
||||
:summary="socialSummary"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField :label="t('Social title')" optional>
|
||||
<UInput
|
||||
:model-value="seo.social_title"
|
||||
:placeholder="seo.seo_title || articleTitle"
|
||||
:maxlength="SEO_LIMITS.social_title"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="fieldUi"
|
||||
@update:model-value="typeText('social_title', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip v-if="!seo.social_title" :label="chipLabel('meta_title')" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField :label="t('Social description')" optional>
|
||||
<UTextarea
|
||||
:model-value="seo.social_description"
|
||||
:placeholder="descriptionPlaceholder"
|
||||
:maxlength="SEO_LIMITS.social_description"
|
||||
:rows="3"
|
||||
class="w-full"
|
||||
:ui="areaUi"
|
||||
@update:model-value="typeText('social_description', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip
|
||||
v-if="!seo.social_description"
|
||||
:label="chipLabel('meta_description')"
|
||||
/>
|
||||
</div>
|
||||
<MediaField
|
||||
:model-value="seo.social_image_id"
|
||||
:item="socialItem"
|
||||
:label="t('Social image')"
|
||||
:help="seo.social_image_id === null ? t('Using the cover image.') : ''"
|
||||
:picker="socialPicker"
|
||||
:disabled="saving"
|
||||
@update:item="remember"
|
||||
@update:model-value="setSocialImage"
|
||||
/>
|
||||
</SeoDisclosure>
|
||||
<SeoSocialSection
|
||||
:seo="seo"
|
||||
:article-title="articleTitle"
|
||||
:description-placeholder="descriptionPlaceholder"
|
||||
:social-item="socialItem"
|
||||
:saving="saving"
|
||||
@type="typeText"
|
||||
@remember="remember"
|
||||
@image="setSocialImage"
|
||||
/>
|
||||
|
||||
<SeoDisclosure
|
||||
<SeoAdvancedSection
|
||||
v-model:open="advancedOpen"
|
||||
:title="t('Advanced')"
|
||||
:canonical-url="seo.canonical_url"
|
||||
:placeholder="slugUrl"
|
||||
:summary="advancedSummary"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField
|
||||
:label="t('Canonical URL')"
|
||||
optional
|
||||
:error="canonicalError"
|
||||
:help="t('Only change this if the same content lives at another address.')"
|
||||
>
|
||||
<UInput
|
||||
:model-value="seo.canonical_url"
|
||||
:placeholder="canonicalPlaceholder"
|
||||
:maxlength="SEO_LIMITS.canonical_url"
|
||||
:color="canonicalError ? 'error' : undefined"
|
||||
:aria-invalid="canonicalError ? 'true' : undefined"
|
||||
inputmode="url"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="monoFieldUi"
|
||||
@update:model-value="typeCanonical(String($event))"
|
||||
@focusout="checkCanonical"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip v-if="!seo.canonical_url" :label="chipLabel('canonical')" />
|
||||
</div>
|
||||
</SeoDisclosure>
|
||||
:error="canonicalError"
|
||||
@type="typeCanonical"
|
||||
@leave="canonicalBlocked"
|
||||
/>
|
||||
|
||||
<USwitch
|
||||
:model-value="seo.noindex"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import FallbackChip from '@/components/FallbackChip.vue'
|
||||
import MediaField from '@/components/MediaField.vue'
|
||||
import SeoDisclosure from '@/components/SeoDisclosure.vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { SEO_LIMITS, type ArticleSeo } from '@/lib/articleSeo'
|
||||
import { chipLabel } from '@/lib/seoCopy'
|
||||
import { hasSocialOverrides } from '@/lib/seoPreview'
|
||||
import { areaUi, fieldUi } from '@/lib/seoFieldUi'
|
||||
|
||||
const props = defineProps<{
|
||||
seo: ArticleSeo
|
||||
articleTitle: string
|
||||
descriptionPlaceholder: string
|
||||
socialItem: MediaItem | null
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
type: [key: 'social_title' | 'social_description', value: string]
|
||||
remember: [item: MediaItem | null]
|
||||
image: [id: number | null]
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
|
||||
const picker = computed(() => ({
|
||||
title: t('Choose a social image'),
|
||||
description: t('The image shown when this article is shared.'),
|
||||
confirmLabel: t('Use image'),
|
||||
}))
|
||||
const summary = computed(() => {
|
||||
if (hasSocialOverrides(props.seo)) {
|
||||
return t('Customised')
|
||||
}
|
||||
return open.value ? '' : t('Uses the meta values')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SeoDisclosure
|
||||
v-model:open="open"
|
||||
:title="t('Social sharing')"
|
||||
:tag="t('Optional')"
|
||||
:summary="summary"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField :label="t('Social title')" optional>
|
||||
<UInput
|
||||
:model-value="seo.social_title"
|
||||
:placeholder="seo.seo_title || articleTitle"
|
||||
:maxlength="SEO_LIMITS.social_title"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="fieldUi"
|
||||
@update:model-value="emit('type', 'social_title', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip v-if="!seo.social_title" :label="chipLabel('meta_title')" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField :label="t('Social description')" optional>
|
||||
<UTextarea
|
||||
:model-value="seo.social_description"
|
||||
:placeholder="descriptionPlaceholder"
|
||||
:maxlength="SEO_LIMITS.social_description"
|
||||
:rows="3"
|
||||
class="w-full"
|
||||
:ui="areaUi"
|
||||
@update:model-value="emit('type', 'social_description', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip v-if="!seo.social_description" :label="chipLabel('meta_description')" />
|
||||
</div>
|
||||
<MediaField
|
||||
:model-value="seo.social_image_id"
|
||||
:item="socialItem"
|
||||
:label="t('Social image')"
|
||||
:help="seo.social_image_id === null ? t('Using the cover image.') : ''"
|
||||
:picker="picker"
|
||||
:disabled="saving"
|
||||
@update:item="emit('remember', $event)"
|
||||
@update:model-value="emit('image', $event)"
|
||||
/>
|
||||
</SeoDisclosure>
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const request = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: () => ({ request }) }
|
||||
})
|
||||
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import {
|
||||
MEDIA_FAILURE_CACHE_LIMIT,
|
||||
MEDIA_LOOKUP_CONCURRENCY,
|
||||
useMediaNames,
|
||||
} from '@/composables/useMediaNames'
|
||||
|
||||
function mediaOf(id: number, filename = `image-${id}.jpg`) {
|
||||
return { id, filename } as MediaItem
|
||||
}
|
||||
|
||||
describe('useMediaNames', () => {
|
||||
beforeEach(() => {
|
||||
request.mockReset()
|
||||
request.mockImplementation(async (path: string) => {
|
||||
const id = Number(path.split('/').pop())
|
||||
return mediaOf(id)
|
||||
})
|
||||
})
|
||||
|
||||
it('looks each wanted id up by id, whatever the size of the library', async () => {
|
||||
const names = useMediaNames()
|
||||
|
||||
await names.resolve([4, 250])
|
||||
|
||||
expect(request.mock.calls.map(([path]) => path).sort()).toEqual([
|
||||
'/v1/media/250',
|
||||
'/v1/media/4',
|
||||
])
|
||||
expect(names.itemOf(250)?.filename).toBe('image-250.jpg')
|
||||
expect(names.itemOf(4)?.filename).toBe('image-4.jpg')
|
||||
})
|
||||
|
||||
it('skips empty ids and asks once for a repeated id', async () => {
|
||||
const names = useMediaNames()
|
||||
|
||||
await names.resolve([null, undefined, 3, 3])
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/3')
|
||||
})
|
||||
|
||||
it('does not ask again for an id it already knows', async () => {
|
||||
const names = useMediaNames()
|
||||
await names.resolve([3])
|
||||
request.mockClear()
|
||||
|
||||
await names.resolve([3])
|
||||
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not ask for an id handed over with remember', async () => {
|
||||
const names = useMediaNames()
|
||||
names.remember(mediaOf(8, 'picked.jpg'))
|
||||
|
||||
await names.resolve([8])
|
||||
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
expect(names.itemOf(8)?.filename).toBe('picked.jpg')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failure: '404', error: new ApiError(404) },
|
||||
{ failure: '500', error: new ApiError(500) },
|
||||
{ failure: 'a network error', error: new TypeError('Failed to fetch') },
|
||||
])('stays silent and keeps the other names on $failure', async ({ error }) => {
|
||||
request.mockImplementation(async (path: string) => {
|
||||
if (path === '/v1/media/9') {
|
||||
throw error
|
||||
}
|
||||
return mediaOf(Number(path.split('/').pop()))
|
||||
})
|
||||
const names = useMediaNames()
|
||||
|
||||
await expect(names.resolve([9, 2])).resolves.toBeUndefined()
|
||||
|
||||
expect(names.itemOf(9)).toBeNull()
|
||||
expect(names.itemOf(2)?.filename).toBe('image-2.jpg')
|
||||
})
|
||||
|
||||
it('never has more than the cap of lookups in flight', async () => {
|
||||
let inFlight = 0
|
||||
let peak = 0
|
||||
request.mockImplementation(async (path: string) => {
|
||||
inFlight += 1
|
||||
peak = Math.max(peak, inFlight)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
inFlight -= 1
|
||||
return mediaOf(Number(path.split('/').pop()))
|
||||
})
|
||||
const names = useMediaNames()
|
||||
|
||||
await names.resolve(Array.from({ length: 20 }, (_, index) => index + 1))
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(20)
|
||||
expect(peak).toBe(MEDIA_LOOKUP_CONCURRENCY)
|
||||
expect(names.itemOf(20)?.filename).toBe('image-20.jpg')
|
||||
})
|
||||
|
||||
it('does not ask again for an id that failed earlier in the session', async () => {
|
||||
request.mockRejectedValue(new ApiError(404))
|
||||
const names = useMediaNames()
|
||||
await names.resolve([9])
|
||||
request.mockClear()
|
||||
|
||||
await names.resolve([9, 9])
|
||||
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
expect(names.itemOf(9)).toBeNull()
|
||||
})
|
||||
|
||||
it('forgets the oldest failures once the failure cache is full', async () => {
|
||||
request.mockRejectedValue(new ApiError(500))
|
||||
const names = useMediaNames()
|
||||
const ids = Array.from({ length: MEDIA_FAILURE_CACHE_LIMIT + 1 }, (_, index) => index + 1)
|
||||
await names.resolve(ids)
|
||||
request.mockClear()
|
||||
|
||||
await names.resolve(ids)
|
||||
|
||||
expect(request.mock.calls.map(([path]) => path)).toEqual(['/v1/media/1'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const request = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: () => ({ request }) }
|
||||
})
|
||||
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { SEO_CHECK_CACHE_LIMIT, useSeoCheck } from '@/composables/useSeoCheck'
|
||||
import type { SeoCheckBody } from '@/lib/seoTypes'
|
||||
|
||||
function answerFor(init?: RequestInit) {
|
||||
const sent = JSON.parse(String(init?.body))
|
||||
return {
|
||||
status: 'ready',
|
||||
suggestions: [],
|
||||
slug: { available: true, suggestion: sent.slug ?? null },
|
||||
}
|
||||
}
|
||||
|
||||
function mountCheck(initial: Record<string, unknown>) {
|
||||
const state = { body: initial as SeoCheckBody }
|
||||
let check!: ReturnType<typeof useSeoCheck>
|
||||
mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
check = useSeoCheck({
|
||||
articleId: 12,
|
||||
body: () => state.body,
|
||||
onGone: () => {},
|
||||
})
|
||||
return () => null
|
||||
},
|
||||
}),
|
||||
)
|
||||
return { check, state }
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
seo_title: 'Title',
|
||||
seo_description: 'Description',
|
||||
excerpt: 'Excerpt',
|
||||
slug: 'a-slug',
|
||||
cover_image_id: 1,
|
||||
social_image_id: 2,
|
||||
noindex: false,
|
||||
canonical_url: 'https://example.com/a',
|
||||
body: '<p>Body</p>',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
request.mockReset()
|
||||
request.mockImplementation(async (_path: string, init?: RequestInit) => answerFor(init))
|
||||
})
|
||||
|
||||
describe('SEO check answer cache', () => {
|
||||
it('checks once when only the key order of the fields differs', async () => {
|
||||
const { check, state } = mountCheck({ slug: 'a-slug', seo_title: 'Title' })
|
||||
await check.runNow()
|
||||
|
||||
state.body = { seo_title: 'Title', slug: 'a-slug' }
|
||||
await check.runNow()
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses the cached answer on restore when only the key order differs', async () => {
|
||||
const { check, state } = mountCheck({ slug: 'a-slug', seo_title: 'Title' })
|
||||
await check.runNow()
|
||||
state.body = { slug: 'other' }
|
||||
await check.runFresh()
|
||||
|
||||
state.body = { seo_title: 'Title', slug: 'a-slug' }
|
||||
check.restore()
|
||||
|
||||
expect(check.checkedSlug.value).toBe('a-slug')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['seo_title', 'Another title'],
|
||||
['body', '<p>Another body</p>'],
|
||||
['seo_description', 'Another description'],
|
||||
['excerpt', 'Another excerpt'],
|
||||
['slug', 'another-slug'],
|
||||
['cover_image_id', 7],
|
||||
['social_image_id', 8],
|
||||
['noindex', true],
|
||||
['canonical_url', 'https://example.com/b'],
|
||||
])('checks again when %s changes', async (field, value) => {
|
||||
const { check, state } = mountCheck(BASE)
|
||||
await check.runNow()
|
||||
|
||||
state.body = { ...BASE, [field]: value } as SeoCheckBody
|
||||
await check.runNow()
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not check again when only a field the check ignores changes', async () => {
|
||||
const { check, state } = mountCheck(BASE)
|
||||
await check.runNow()
|
||||
|
||||
state.body = { ...BASE, unrelated: 'changed' } as SeoCheckBody
|
||||
await check.runNow()
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not cache a failed check', async () => {
|
||||
request.mockRejectedValueOnce(new ApiError(500))
|
||||
const { check } = mountCheck(BASE)
|
||||
await check.runNow()
|
||||
expect(check.phase.value).toBe('failed')
|
||||
|
||||
check.restore()
|
||||
expect(check.result.value).toBeNull()
|
||||
|
||||
await check.runNow()
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
expect(check.phase.value).toBe('ready')
|
||||
})
|
||||
|
||||
it('stays at its limit after 200 distinct inputs and still hits the latest one', async () => {
|
||||
const { check, state } = mountCheck({ slug: 'input-0' })
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
state.body = { slug: `input-${index}` }
|
||||
await check.runFresh()
|
||||
}
|
||||
request.mockClear()
|
||||
|
||||
state.body = { slug: 'input-198' }
|
||||
check.restore()
|
||||
expect(check.checkedSlug.value).toBe('input-198')
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
|
||||
state.body = { slug: 'input-0' }
|
||||
check.restore()
|
||||
expect(check.checkedSlug.value).toBe('input-198')
|
||||
expect(SEO_CHECK_CACHE_LIMIT).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('keeps a recently restored input when the cache overflows', async () => {
|
||||
const { check, state } = mountCheck({ slug: 'input-0' })
|
||||
for (let index = 0; index < SEO_CHECK_CACHE_LIMIT; index += 1) {
|
||||
state.body = { slug: `input-${index}` }
|
||||
await check.runFresh()
|
||||
}
|
||||
state.body = { slug: 'input-0' }
|
||||
check.restore()
|
||||
state.body = { slug: `input-${SEO_CHECK_CACHE_LIMIT}` }
|
||||
await check.runFresh()
|
||||
|
||||
state.body = { slug: 'input-0' }
|
||||
check.restore()
|
||||
expect(check.checkedSlug.value).toBe('input-0')
|
||||
|
||||
state.body = { slug: 'input-1' }
|
||||
check.restore()
|
||||
expect(check.checkedSlug.value).toBe('input-0')
|
||||
expect(check.result.value?.slug?.suggestion).toBe('input-0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useArticleActions } from '@/composables/useArticleActions'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
|
||||
export interface OnlineChange {
|
||||
prepare?: () => Promise<boolean>
|
||||
pending: (busy: boolean) => void
|
||||
onUpdated: (updated: Article) => void
|
||||
onGone: () => void
|
||||
}
|
||||
|
||||
export interface RemovalHandlers {
|
||||
onDeleted: () => void
|
||||
onGone: () => void
|
||||
}
|
||||
|
||||
type ArticleRef = Pick<Article, 'id' | 'title'>
|
||||
|
||||
export function useArticleLifecycle() {
|
||||
const actions = useArticleActions()
|
||||
let deleteDialogOpen = false
|
||||
|
||||
async function changeOnline(article: ArticleRef, makeOnline: boolean, change: OnlineChange) {
|
||||
change.pending(true)
|
||||
try {
|
||||
if (change.prepare && !(await change.prepare())) {
|
||||
return
|
||||
}
|
||||
await actions.publish(article, makeOnline, {
|
||||
onUpdated: change.onUpdated,
|
||||
onGone: change.onGone,
|
||||
retry: () => changeOnline(article, makeOnline, change),
|
||||
})
|
||||
} finally {
|
||||
change.pending(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAndDelete(article: Article, handlers: RemovalHandlers) {
|
||||
if (deleteDialogOpen) {
|
||||
return false
|
||||
}
|
||||
deleteDialogOpen = true
|
||||
const confirmed = await actions
|
||||
.confirmDelete(article, () => actions.deleteArticle(article, handlers))
|
||||
.finally(() => {
|
||||
deleteDialogOpen = false
|
||||
})
|
||||
return Boolean(confirmed)
|
||||
}
|
||||
|
||||
return { changeOnline, confirmAndDelete }
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
useArticles,
|
||||
type Article,
|
||||
type ArticleCounts,
|
||||
type ArticleStatus,
|
||||
} from '@/composables/useArticles'
|
||||
import {
|
||||
ARTICLE_PAGE_SIZES,
|
||||
SEO_FILTER,
|
||||
nextSort,
|
||||
parseArticleQuery,
|
||||
sameArticleQuery,
|
||||
sameRouteQuery,
|
||||
toRouteQuery,
|
||||
type ArticleListQuery,
|
||||
type ArticleSortField,
|
||||
} from '@/lib/articleListQuery'
|
||||
import { pageCountFor } from '@/lib/pagination'
|
||||
import { ARTICLES_PAGE_SIZE_KEY, readStoredPageSize, storePageSize } from '@/lib/pageSizeMemory'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const SEARCH_DEBOUNCE_MS = 120
|
||||
const ARTICLES_PATH = '/article'
|
||||
|
||||
export type UrlMode = 'push' | 'replace'
|
||||
export type ArticleListNotice =
|
||||
| { kind: 'range' }
|
||||
| { kind: 'error'; query: ArticleListQuery; mode: UrlMode }
|
||||
|
||||
interface ArticleListOptions {
|
||||
sitesSettled: () => boolean
|
||||
usableSite: (query: ArticleListQuery) => ArticleListQuery
|
||||
}
|
||||
|
||||
export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { listArticles } = useArticles()
|
||||
|
||||
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 items = ref<Article[]>([])
|
||||
const total = ref(0)
|
||||
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
|
||||
const shown = ref<ArticleListQuery>(parseWithStoredSize(route.query))
|
||||
const desired = ref<ArticleListQuery>(shown.value)
|
||||
const hasLoaded = ref(false)
|
||||
const loading = ref(true)
|
||||
const loadFailed = ref(false)
|
||||
const pendingPage = ref<number | null>(null)
|
||||
const notice = ref<ArticleListNotice | null>(null)
|
||||
const showSkeleton = ref(false)
|
||||
const searchInput = ref(shown.value.q)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let requestToken = 0
|
||||
let syncedTarget: Record<string, string> | null = null
|
||||
|
||||
const needsAttentionCount = computed(() => counts.value.suggestions ?? null)
|
||||
const settled = computed(() => hasLoaded.value && !loadFailed.value)
|
||||
const skeletonActive = computed(() => loading.value && showSkeleton.value)
|
||||
const showPaginator = computed(() => settled.value && total.value > 0)
|
||||
const pageCount = computed(() => pageCountFor(total.value, shown.value.size))
|
||||
|
||||
function clearSearchTimer() {
|
||||
if (searchDebounceTimer !== null) {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
clearTimeout(skeletonTimer)
|
||||
skeletonTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(searchInput, (value) => {
|
||||
clearSearchTimer()
|
||||
searchDebounceTimer = setTimeout(() => changeSearch(value), SEARCH_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
async function load(
|
||||
query: ArticleListQuery,
|
||||
mode: UrlMode,
|
||||
successNotice: ArticleListNotice | null = null,
|
||||
) {
|
||||
const token = ++requestToken
|
||||
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
|
||||
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 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')
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
clearSearchTimer()
|
||||
searchInput.value = ''
|
||||
changeSearch('')
|
||||
}
|
||||
|
||||
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() ? 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')
|
||||
},
|
||||
)
|
||||
|
||||
async function start(sitesRequest: Promise<unknown>) {
|
||||
if (shown.value.site) {
|
||||
await sitesRequest
|
||||
const usable = usableSite(shown.value)
|
||||
shown.value = usable
|
||||
desired.value = usable
|
||||
}
|
||||
load(shown.value, 'replace')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
requestToken++
|
||||
clearSearchTimer()
|
||||
clearSkeletonTimer()
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
counts,
|
||||
shown,
|
||||
desired,
|
||||
hasLoaded,
|
||||
loading,
|
||||
loadFailed,
|
||||
pendingPage,
|
||||
notice,
|
||||
showSkeleton,
|
||||
searchInput,
|
||||
needsAttentionCount,
|
||||
settled,
|
||||
skeletonActive,
|
||||
showPaginator,
|
||||
pageCount,
|
||||
load,
|
||||
start,
|
||||
changePage,
|
||||
changeSize,
|
||||
changeStatus,
|
||||
changeSite,
|
||||
changeSeo,
|
||||
changeSort,
|
||||
changeSearch,
|
||||
clearSearch,
|
||||
retryNotice,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { nextTick, ref, type Ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { useArticleActions } from '@/composables/useArticleActions'
|
||||
import { useArticleLifecycle } from '@/composables/useArticleLifecycle'
|
||||
import type { Article, ArticleCounts } from '@/composables/useArticles'
|
||||
import type { ArticleListQuery } from '@/lib/articleListQuery'
|
||||
import { pageCountFor } from '@/lib/pagination'
|
||||
@@ -21,7 +21,7 @@ export function useArticleRowActions({
|
||||
shown,
|
||||
reload,
|
||||
}: ArticleRowActionsState) {
|
||||
const actions = useArticleActions()
|
||||
const lifecycle = useArticleLifecycle()
|
||||
const toast = useLocalizedToast()
|
||||
const busyIds = ref<Set<number>>(new Set())
|
||||
|
||||
@@ -93,17 +93,12 @@ export function useArticleRowActions({
|
||||
}
|
||||
}
|
||||
|
||||
async function setOnline(article: Article, makeOnline: boolean) {
|
||||
setBusy(article.id, true)
|
||||
try {
|
||||
await actions.publish(article, makeOnline, {
|
||||
onUpdated: (updated) => replaceArticle(updated, article),
|
||||
onGone: () => goneFromList(article),
|
||||
retry: () => setOnline(article, makeOnline),
|
||||
})
|
||||
} finally {
|
||||
setBusy(article.id, false)
|
||||
}
|
||||
function setOnline(article: Article, makeOnline: boolean) {
|
||||
return lifecycle.changeOnline(article, makeOnline, {
|
||||
pending: (busy) => setBusy(article.id, busy),
|
||||
onUpdated: (updated) => replaceArticle(updated, article),
|
||||
onGone: () => goneFromList(article),
|
||||
})
|
||||
}
|
||||
|
||||
function findItem(id: number) {
|
||||
@@ -162,13 +157,6 @@ export function useArticleRowActions({
|
||||
toast.add(() => notFoundToast(article))
|
||||
}
|
||||
|
||||
function performDelete(article: Article) {
|
||||
return actions.deleteArticle(article, {
|
||||
onDeleted: () => removeArticle(article),
|
||||
onGone: () => goneFromList(article),
|
||||
})
|
||||
}
|
||||
|
||||
async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) {
|
||||
await nextTick()
|
||||
const index = idsAtOpen.indexOf(deletedId)
|
||||
@@ -180,19 +168,12 @@ export function useArticleRowActions({
|
||||
document.querySelector<HTMLElement>('[data-testid="articles-heading"]')?.focus()
|
||||
}
|
||||
|
||||
let deleteDialogOpen = false
|
||||
|
||||
async function handleDelete(article: Article) {
|
||||
if (deleteDialogOpen) {
|
||||
return
|
||||
}
|
||||
deleteDialogOpen = true
|
||||
const idsAtOpen = items.value.map((existing) => existing.id)
|
||||
const confirmed = await actions
|
||||
.confirmDelete(article, () => performDelete(article))
|
||||
.finally(() => {
|
||||
deleteDialogOpen = false
|
||||
})
|
||||
const confirmed = await lifecycle.confirmAndDelete(article, {
|
||||
onDeleted: () => removeArticle(article),
|
||||
onGone: () => goneFromList(article),
|
||||
})
|
||||
if (confirmed) {
|
||||
focusRowOrHeading(idsAtOpen, article.id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useSites, type Site } from '@/composables/useSites'
|
||||
import type { ArticleListQuery } from '@/lib/articleListQuery'
|
||||
|
||||
export function useArticleSites(shownSite: () => string) {
|
||||
const { listSites } = useSites()
|
||||
const sites = ref<Site[]>([])
|
||||
const sitesSettled = ref(false)
|
||||
|
||||
const severalSites = computed(() => sites.value.length > 1)
|
||||
const siteName = computed(
|
||||
() => sites.value.find((site) => site.key === shownSite())?.name ?? shownSite(),
|
||||
)
|
||||
|
||||
async function fetchSites() {
|
||||
try {
|
||||
sites.value = await listSites()
|
||||
} catch {
|
||||
sites.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function loadSites() {
|
||||
return fetchSites().finally(() => {
|
||||
sitesSettled.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function usableSite(query: ArticleListQuery): ArticleListQuery {
|
||||
const known = severalSites.value && sites.value.some((site) => site.key === query.site)
|
||||
return query.site && !known ? { ...query, site: '' } : query
|
||||
}
|
||||
|
||||
return { sites, sitesSettled, severalSites, siteName, loadSites, usableSite }
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export interface ArticleCounts {
|
||||
all: number
|
||||
online: number
|
||||
draft: number
|
||||
suggestions?: number
|
||||
}
|
||||
|
||||
export interface ArticlePage {
|
||||
@@ -46,12 +47,6 @@ export interface ListArticlesParams {
|
||||
seo?: '' | typeof SEO_FILTER
|
||||
}
|
||||
|
||||
export interface CountNeedsAttentionParams {
|
||||
q?: string
|
||||
status?: ArticleStatus | ''
|
||||
site?: string
|
||||
}
|
||||
|
||||
export function useArticles() {
|
||||
const { request } = useApi()
|
||||
|
||||
@@ -79,20 +74,5 @@ export function useArticles() {
|
||||
return request<ArticlePage>(`/v1/article/?${query.toString()}`)
|
||||
}
|
||||
|
||||
async function countNeedsAttention(params: CountNeedsAttentionParams): Promise<number> {
|
||||
const query = new URLSearchParams({ seo: 'suggestions', page: '1', size: '5' })
|
||||
if (params.q) {
|
||||
query.set('q', params.q)
|
||||
}
|
||||
if (params.status) {
|
||||
query.set('status', params.status)
|
||||
}
|
||||
if (params.site) {
|
||||
query.set('site', params.site)
|
||||
}
|
||||
const page = await request<Pick<ArticlePage, 'total'>>(`/v1/article/?${query.toString()}`)
|
||||
return page.total
|
||||
}
|
||||
|
||||
return { listArticles, countNeedsAttention }
|
||||
return { listArticles }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { nextTick, type Ref } from 'vue'
|
||||
import type ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
|
||||
const FOCUS_RETRIES = 10
|
||||
const FOCUS_RETRY_MS = 30
|
||||
|
||||
interface EditorFocusOptions {
|
||||
editorRef: Ref<InstanceType<typeof ArticleEditor> | null>
|
||||
detailsOpen: Ref<boolean>
|
||||
previewOn: Ref<boolean>
|
||||
metaRemembered: Ref<boolean | null>
|
||||
}
|
||||
|
||||
export function useEditorFocus({
|
||||
editorRef,
|
||||
detailsOpen,
|
||||
previewOn,
|
||||
metaRemembered,
|
||||
}: EditorFocusOptions) {
|
||||
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() {
|
||||
metaRemembered.value = true
|
||||
focusWhenRendered(authorsFocusTarget)
|
||||
}
|
||||
|
||||
return { focusBody, focusAuthors }
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import type { SaveOutcome, useArticleDraft } from '@/composables/useArticleDraft'
|
||||
import { useSeoCheck } from '@/composables/useSeoCheck'
|
||||
import { SEO_LIMITS, charCount, seoChanges, type ArticleSeo } from '@/lib/articleSeo'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
import type { SeoCheckBody, SeoFocus, SuggestionField } from '@/lib/seoTypes'
|
||||
|
||||
const CLOSING_OUTCOMES: SaveOutcome[] = ['not-found', 'site-gone', 'author-gone', 'unauthorized']
|
||||
|
||||
interface EditorSeoPanelOptions {
|
||||
articleId: number
|
||||
draft: ReturnType<typeof useArticleDraft>
|
||||
save: () => Promise<SaveOutcome>
|
||||
onGone: () => void
|
||||
focusBody: () => void
|
||||
focusAuthors: () => void
|
||||
}
|
||||
|
||||
export function useEditorSeoPanel({
|
||||
articleId,
|
||||
draft,
|
||||
save,
|
||||
onGone,
|
||||
focusBody,
|
||||
focusAuthors,
|
||||
}: EditorSeoPanelOptions) {
|
||||
const toast = useLocalizedToast()
|
||||
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)
|
||||
let panelSaveFailed = false
|
||||
|
||||
const seoInEffect = computed(() => panelValues.value ?? draft.seo.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,
|
||||
onUnauthorized: draft.mirrorToStorage,
|
||||
})
|
||||
|
||||
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 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.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function revealSaveFailure(outcome: SaveOutcome) {
|
||||
if (!panelOpen.value && (outcome === 'slug-taken' || outcome === 'invalid-url')) {
|
||||
openPanel('slug')
|
||||
showPanelFailure(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
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 save()
|
||||
panelSaving.value = false
|
||||
if (outcome === 'saved') {
|
||||
closePanel(false)
|
||||
announceSeoSaved()
|
||||
return
|
||||
}
|
||||
if (CLOSING_OUTCOMES.includes(outcome)) {
|
||||
closePanel()
|
||||
return
|
||||
}
|
||||
showPanelFailure(outcome)
|
||||
}
|
||||
|
||||
return {
|
||||
check,
|
||||
panelOpen,
|
||||
panelValues,
|
||||
panelFocus,
|
||||
panelNotice,
|
||||
panelSaving,
|
||||
slugTakenOnSave,
|
||||
canonicalRejected,
|
||||
openPanel,
|
||||
closePanel,
|
||||
updatePanelValues,
|
||||
onPanelCheck,
|
||||
goToField,
|
||||
revealSaveFailure,
|
||||
saveSeoPanel,
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,49 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { MediaItem, MediaListResponse } from '@/composables/useMedia'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
|
||||
const LOOKUP_QUERY = 'page=1&size=100&sort=newest'
|
||||
export const MEDIA_LOOKUP_CONCURRENCY = 4
|
||||
export const MEDIA_FAILURE_CACHE_LIMIT = 50
|
||||
|
||||
export function useMediaNames() {
|
||||
const { request } = useApi()
|
||||
const items = ref<Record<number, MediaItem>>({})
|
||||
const failed = new Set<number>()
|
||||
|
||||
function remember(item: MediaItem) {
|
||||
items.value = { ...items.value, [item.id]: item }
|
||||
}
|
||||
|
||||
function rememberFailure(id: number) {
|
||||
failed.delete(id)
|
||||
failed.add(id)
|
||||
if (failed.size > MEDIA_FAILURE_CACHE_LIMIT) {
|
||||
failed.delete(failed.values().next().value as number)
|
||||
}
|
||||
}
|
||||
|
||||
async function lookUp(id: number) {
|
||||
try {
|
||||
remember(await request<MediaItem>(`/v1/media/${id}`))
|
||||
} catch {
|
||||
rememberFailure(id)
|
||||
}
|
||||
}
|
||||
|
||||
async function lookUpAll(queue: number[]) {
|
||||
const next = queue.values()
|
||||
const worker = async () => {
|
||||
for (const id of next) {
|
||||
await lookUp(id)
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: MEDIA_LOOKUP_CONCURRENCY }, worker))
|
||||
}
|
||||
|
||||
async function resolve(ids: (number | null | undefined)[]) {
|
||||
const wanted = ids.filter((id): id is number => typeof id === 'number')
|
||||
if (wanted.every((id) => id in items.value)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const page = await request<MediaListResponse>(`/v1/media/?${LOOKUP_QUERY}`)
|
||||
const found = (page?.items ?? []).filter((item) => wanted.includes(item.id))
|
||||
found.forEach(remember)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const missing = [...new Set(wanted)].filter((id) => !(id in items.value) && !failed.has(id))
|
||||
await lookUpAll(missing)
|
||||
}
|
||||
|
||||
function itemOf(id: number | null | undefined) {
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import { SEO_KEYS } from '@/lib/articleSeo'
|
||||
import type { SeoCheckBody, SeoCheckResult } from '@/lib/seoTypes'
|
||||
|
||||
export const SEO_CHECK_DEBOUNCE_MS = 500
|
||||
export const SEO_CHECK_CACHE_LIMIT = 50
|
||||
|
||||
const CHECKED_FIELDS = [...SEO_KEYS, 'body'].sort() as (keyof SeoCheckBody)[]
|
||||
|
||||
function checkKey(body: SeoCheckBody) {
|
||||
return JSON.stringify(
|
||||
CHECKED_FIELDS.filter((field) => body[field] !== undefined).map((field) => [
|
||||
field,
|
||||
body[field],
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
export type SeoCheckPhase = 'idle' | 'pending' | 'ready' | 'failed'
|
||||
|
||||
@@ -25,7 +38,7 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let controller: AbortController | null = null
|
||||
let ticket = 0
|
||||
let lastPayload: string | null = null
|
||||
let lastKey: string | null = null
|
||||
const answers = new Map<string, { result: SeoCheckResult; slug: string | undefined }>()
|
||||
|
||||
function clearTimer() {
|
||||
@@ -43,7 +56,15 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
}, SEO_CHECK_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
async function send(body: SeoCheckBody, payload: string, silent: boolean) {
|
||||
function remember(key: string, answer: { result: SeoCheckResult; slug: string | undefined }) {
|
||||
answers.delete(key)
|
||||
answers.set(key, answer)
|
||||
if (answers.size > SEO_CHECK_CACHE_LIMIT) {
|
||||
answers.delete(answers.keys().next().value as string)
|
||||
}
|
||||
}
|
||||
|
||||
async function send(body: SeoCheckBody, key: string, silent: boolean) {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
const current = ++ticket
|
||||
@@ -56,7 +77,7 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
@@ -66,7 +87,7 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
result.value = answer
|
||||
checkedSlug.value = body.slug
|
||||
phase.value = 'ready'
|
||||
answers.set(payload, { result: answer, slug: body.slug })
|
||||
remember(key, { result: answer, slug: body.slug })
|
||||
} catch (err) {
|
||||
if (current !== ticket) {
|
||||
return
|
||||
@@ -86,12 +107,12 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
function run(force = false, silent = false) {
|
||||
clearTimer()
|
||||
const body = options.body()
|
||||
const payload = JSON.stringify(body)
|
||||
if (!force && payload === lastPayload && phase.value !== 'failed') {
|
||||
const key = checkKey(body)
|
||||
if (!force && key === lastKey && phase.value !== 'failed') {
|
||||
return Promise.resolve()
|
||||
}
|
||||
lastPayload = payload
|
||||
return send(body, payload, silent)
|
||||
lastKey = key
|
||||
return send(body, key, silent)
|
||||
}
|
||||
|
||||
function runNow() {
|
||||
@@ -123,17 +144,18 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
}
|
||||
|
||||
function restore() {
|
||||
const payload = JSON.stringify(options.body())
|
||||
const answer = answers.get(payload)
|
||||
const key = checkKey(options.body())
|
||||
const answer = answers.get(key)
|
||||
clearTimer()
|
||||
controller?.abort()
|
||||
ticket += 1
|
||||
if (!answer) {
|
||||
lastPayload = null
|
||||
lastKey = null
|
||||
phase.value = result.value ? 'ready' : 'idle'
|
||||
return
|
||||
}
|
||||
lastPayload = payload
|
||||
remember(key, answer)
|
||||
lastKey = key
|
||||
result.value = answer.result
|
||||
checkedSlug.value = answer.slug
|
||||
phase.value = 'ready'
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import type { useSeoCheck } from '@/composables/useSeoCheck'
|
||||
import type { SuggestionCode } from '@/lib/seoTypes'
|
||||
|
||||
interface SeoDismissalsOptions {
|
||||
articleId: number
|
||||
check: ReturnType<typeof useSeoCheck>
|
||||
onGone: () => void
|
||||
onUnauthorized: () => void
|
||||
}
|
||||
|
||||
export function useSeoDismissals({
|
||||
articleId,
|
||||
check,
|
||||
onGone,
|
||||
onUnauthorized,
|
||||
}: SeoDismissalsOptions) {
|
||||
const { request } = useApi()
|
||||
const toast = useLocalizedToast()
|
||||
const dismissedCodes = ref<SuggestionCode[]>([])
|
||||
const dismissingCode = ref<SuggestionCode | null>(null)
|
||||
|
||||
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) {
|
||||
onGone()
|
||||
} else if (err instanceof ApiError && err.status === 401) {
|
||||
onUnauthorized()
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
|
||||
return { dismissedCodes, dismissingCode, dismissSuggestion, restoreDismissed }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { computed, ref, watch, type ComputedRef } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { canonicalIsValid, type ArticleSeo } from '@/lib/articleSeo'
|
||||
|
||||
interface SeoPanelCanonicalOptions {
|
||||
seo: ComputedRef<ArticleSeo>
|
||||
rejected: () => boolean
|
||||
update: (patch: Partial<ArticleSeo>) => void
|
||||
}
|
||||
|
||||
export function useSeoPanelCanonical({ seo, rejected, update }: SeoPanelCanonicalOptions) {
|
||||
const advancedOpen = ref(rejected())
|
||||
const canonicalMessage = ref('')
|
||||
|
||||
const advancedSummary = computed(() => {
|
||||
if (advancedOpen.value) {
|
||||
return ''
|
||||
}
|
||||
return seo.value.canonical_url ? t('Canonical URL set') : t('Canonical URL: automatic')
|
||||
})
|
||||
const canonicalError = computed(() =>
|
||||
canonicalMessage.value || rejected()
|
||||
? t('Enter a full address starting with https://')
|
||||
: '',
|
||||
)
|
||||
|
||||
function typeCanonical(value: string) {
|
||||
canonicalMessage.value = ''
|
||||
update({ canonical_url: value })
|
||||
}
|
||||
|
||||
function canonicalBlocked() {
|
||||
if (canonicalIsValid(seo.value.canonical_url)) {
|
||||
return false
|
||||
}
|
||||
canonicalMessage.value = 'invalid'
|
||||
advancedOpen.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
watch(rejected, (isRejected) => {
|
||||
if (isRejected) {
|
||||
advancedOpen.value = true
|
||||
}
|
||||
})
|
||||
|
||||
return { advancedOpen, advancedSummary, canonicalError, typeCanonical, canonicalBlocked }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { computed, onMounted, type ComputedRef } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { useMediaNames } from '@/composables/useMediaNames'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import type { ArticleSeo } from '@/lib/articleSeo'
|
||||
import { socialImageSource } from '@/lib/seoPreview'
|
||||
|
||||
export function useSeoPanelImages(seo: ComputedRef<ArticleSeo>, site: () => Site | undefined) {
|
||||
const names = useMediaNames()
|
||||
|
||||
const siteImageId = computed(() => site()?.default_social_image_id ?? null)
|
||||
const siteImageName = computed(() => {
|
||||
const id = siteImageId.value
|
||||
if (id === null) {
|
||||
return ''
|
||||
}
|
||||
return names.itemOf(id)?.filename ?? t('Image #{0}', [id])
|
||||
})
|
||||
const coverItem = computed(() => names.itemOf(seo.value.cover_image_id))
|
||||
const socialItem = computed(() => names.itemOf(seo.value.social_image_id))
|
||||
const cardSource = computed(() => socialImageSource(seo.value, siteImageId.value !== null))
|
||||
const cardImageItem = computed(() => {
|
||||
const items = {
|
||||
social: socialItem.value,
|
||||
cover: coverItem.value,
|
||||
site: names.itemOf(siteImageId.value),
|
||||
none: null,
|
||||
}
|
||||
return items[cardSource.value]
|
||||
})
|
||||
const cardImageName = computed(() => {
|
||||
const labels = {
|
||||
social: socialItem.value?.filename ?? t('Image #{0}', [seo.value.social_image_id ?? 0]),
|
||||
cover: coverItem.value?.filename ?? t('Image #{0}', [seo.value.cover_image_id ?? 0]),
|
||||
site: siteImageName.value,
|
||||
none: '',
|
||||
}
|
||||
return labels[cardSource.value]
|
||||
})
|
||||
|
||||
function remember(item: MediaItem | null) {
|
||||
if (item) {
|
||||
names.remember(item)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
names.resolve([siteImageId.value, seo.value.cover_image_id, seo.value.social_image_id])
|
||||
})
|
||||
|
||||
return {
|
||||
siteImageId,
|
||||
siteImageName,
|
||||
coverItem,
|
||||
socialItem,
|
||||
cardSource,
|
||||
cardImageItem,
|
||||
cardImageName,
|
||||
remember,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { computed, ref, type ComputedRef } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { SEO_LIMITS, normalizeSlug, typedSlug, type ArticleSeo } from '@/lib/articleSeo'
|
||||
import { slugify } from '@/lib/directory'
|
||||
|
||||
interface SeoPanelSlugOptions {
|
||||
seo: ComputedRef<ArticleSeo>
|
||||
articleTitle: () => string
|
||||
savedSlug: () => string
|
||||
site: () => Site | undefined
|
||||
siteName: ComputedRef<string>
|
||||
slugTakenOnSave: () => string | null
|
||||
slugVerdict: () => { available: boolean; suggestion: string | null } | null
|
||||
checkedSlug: () => string | undefined
|
||||
update: (patch: Partial<ArticleSeo>, immediate?: boolean) => void
|
||||
requestCheck: () => void
|
||||
}
|
||||
|
||||
export function useSeoPanelSlug({
|
||||
seo,
|
||||
articleTitle,
|
||||
savedSlug,
|
||||
site,
|
||||
siteName,
|
||||
slugTakenOnSave,
|
||||
slugVerdict,
|
||||
checkedSlug,
|
||||
update,
|
||||
requestCheck,
|
||||
}: SeoPanelSlugOptions) {
|
||||
const generatedSlug = computed(() => slugify(articleTitle(), SEO_LIMITS.slug))
|
||||
const slugManual = ref(seo.value.slug !== '' && seo.value.slug !== generatedSlug.value)
|
||||
const slugText = ref(seo.value.slug || generatedSlug.value)
|
||||
|
||||
const displaySlug = computed(() => (slugManual.value ? slugText.value : generatedSlug.value))
|
||||
const slugMode = computed(() => {
|
||||
if (slugManual.value) {
|
||||
return 'manual' as const
|
||||
}
|
||||
return generatedSlug.value ? ('auto' as const) : ('empty' as const)
|
||||
})
|
||||
const slugTaken = computed(() => {
|
||||
if (slugTakenOnSave() !== null && slugTakenOnSave() === seo.value.slug) {
|
||||
return true
|
||||
}
|
||||
const verdict = slugVerdict()
|
||||
return (
|
||||
verdict !== null &&
|
||||
!verdict.available &&
|
||||
checkedSlug() !== undefined &&
|
||||
checkedSlug() === seo.value.slug
|
||||
)
|
||||
})
|
||||
const slugSuggestion = computed(() =>
|
||||
slugTaken.value && checkedSlug() === seo.value.slug
|
||||
? (slugVerdict()?.suggestion ?? '')
|
||||
: '',
|
||||
)
|
||||
const slugError = computed(() =>
|
||||
slugTaken.value
|
||||
? t('The slug "{0}" is already used on {1}.', [displaySlug.value, siteName.value])
|
||||
: '',
|
||||
)
|
||||
const slugUrl = computed(() => `${site()?.base_url ?? ''}/${displaySlug.value}`)
|
||||
|
||||
function autoSlug() {
|
||||
return generatedSlug.value === savedSlug() ? savedSlug() : ''
|
||||
}
|
||||
|
||||
function unlockSlug() {
|
||||
slugManual.value = true
|
||||
slugText.value = generatedSlug.value
|
||||
}
|
||||
|
||||
function typeSlug(value: string) {
|
||||
slugText.value = typedSlug(value)
|
||||
update({ slug: normalizeSlug(slugText.value) })
|
||||
}
|
||||
|
||||
function resetSlug() {
|
||||
slugManual.value = false
|
||||
slugText.value = generatedSlug.value
|
||||
update({ slug: autoSlug() }, true)
|
||||
}
|
||||
|
||||
function useSuggestedSlug(suggestion: string) {
|
||||
slugManual.value = true
|
||||
slugText.value = suggestion
|
||||
update({ slug: suggestion }, true)
|
||||
}
|
||||
|
||||
function checkSlug() {
|
||||
if (slugManual.value) {
|
||||
requestCheck()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
displaySlug,
|
||||
slugMode,
|
||||
slugSuggestion,
|
||||
slugError,
|
||||
slugUrl,
|
||||
typeSlug,
|
||||
unlockSlug,
|
||||
resetSlug,
|
||||
useSuggestedSlug,
|
||||
checkSlug,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedOverlay } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import { authRedirectPending } from '@/composables/useApi'
|
||||
|
||||
interface UnsavedChangesOptions {
|
||||
isDirty: () => boolean
|
||||
title: () => string
|
||||
}
|
||||
|
||||
export function useUnsavedChangesGuard({ isDirty, title }: UnsavedChangesOptions) {
|
||||
const overlay = useLocalizedOverlay()
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (authRedirectPending.value || !isDirty()) {
|
||||
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.", [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
|
||||
})
|
||||
}
|
||||
@@ -31,6 +31,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
||||
'Search and sharing': ['GET /v1/article/{id}/seo', 'GET /v1/site/{key}/sitemap'],
|
||||
Media: [
|
||||
'GET /v1/media/',
|
||||
'GET /v1/media/{id}',
|
||||
'POST /v1/media/',
|
||||
'PATCH /v1/media/{id}',
|
||||
'DELETE /v1/media/{id}',
|
||||
@@ -207,7 +208,7 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
|
||||
}
|
||||
|
||||
describe('Catalogue routes', () => {
|
||||
it('holds exactly the 29 key-callable routes in 7 groups, in order', () => {
|
||||
it('holds exactly the 30 key-callable routes in 7 groups, in order', () => {
|
||||
expect(API_GROUPS.map((group) => group.title)).toEqual([
|
||||
'Articles',
|
||||
'Search and sharing',
|
||||
@@ -222,7 +223,7 @@ describe('Catalogue routes', () => {
|
||||
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
|
||||
),
|
||||
).toEqual(EXPECTED_GROUPS)
|
||||
expect(allEndpoints()).toHaveLength(29)
|
||||
expect(allEndpoints()).toHaveLength(30)
|
||||
})
|
||||
|
||||
it('holds the 25 session-only rows in 7 sub-groups', () => {
|
||||
@@ -251,7 +252,7 @@ describe('Catalogue routes', () => {
|
||||
),
|
||||
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
|
||||
]
|
||||
expect(described).toHaveLength(7 + 29 + 25 + 6)
|
||||
expect(described).toHaveLength(7 + 30 + 25 + 6)
|
||||
const blank = described.filter(([, description]) => description.trim() === '')
|
||||
expect(blank.map(([name]) => name)).toEqual([])
|
||||
})
|
||||
@@ -633,7 +634,7 @@ describe('Request bodies and query parameters', () => {
|
||||
const list = find('GET /v1/article/')
|
||||
const body = JSON.parse(list.response)
|
||||
expect(Object.keys(body)).toEqual(['items', 'total', 'page', 'size', 'counts'])
|
||||
expect(Object.keys(body.counts)).toEqual(['all', 'online', 'draft'])
|
||||
expect(Object.keys(body.counts)).toEqual(['all', 'online', 'draft', 'suggestions'])
|
||||
expect(list.errors.map((error) => error.status)).toContain(400)
|
||||
expect(Array.isArray(JSON.parse(list.response))).toBe(false)
|
||||
})
|
||||
|
||||
@@ -502,7 +502,7 @@ const articlePage = () => ({
|
||||
total: 30,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 30, online: 28, draft: 2 },
|
||||
counts: { all: 30, online: 28, draft: 2, suggestions: 3 },
|
||||
})
|
||||
|
||||
const articleIdErrors = [e(400, ID_FORMAT), e(404, 'Article not found')]
|
||||
@@ -581,7 +581,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'art-list',
|
||||
'GET',
|
||||
'/v1/article/',
|
||||
'List one page of articles, newest update first, with the total and the tab counts. Anonymous callers get online articles only.',
|
||||
'List one page of articles, newest update first, with the total and the tab counts (all, online, draft and suggestions). Anonymous callers get online articles only.',
|
||||
'Public',
|
||||
articleErrors(e(400, BODY_MISMATCH)),
|
||||
json(articlePage()),
|
||||
@@ -861,6 +861,16 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
}),
|
||||
{ query: MEDIA_QUERY },
|
||||
),
|
||||
endpoint(
|
||||
'med-get',
|
||||
'GET',
|
||||
'/v1/media/{id}',
|
||||
'Get one image, with the same fields as an item of the list.',
|
||||
'Verified user',
|
||||
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
|
||||
json(mediaItem()),
|
||||
{ pathId: 4 },
|
||||
),
|
||||
endpoint(
|
||||
'med-up',
|
||||
'POST',
|
||||
@@ -1224,9 +1234,9 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'site-list',
|
||||
'GET',
|
||||
'/v1/site/',
|
||||
'List the sites, the default first and then by name, each with its article count.',
|
||||
'List the sites, the default first and then by name, each with its article count. Without page or size every matching site is returned; the number of matching sites is in the X-Total-Count header.',
|
||||
'Verified user',
|
||||
verified([e(401, UNAUTHORIZED)]),
|
||||
verified([e(400, BODY_MISMATCH), e(401, UNAUTHORIZED)]),
|
||||
json([
|
||||
site(),
|
||||
site({
|
||||
@@ -1244,6 +1254,18 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
default_description: '',
|
||||
}),
|
||||
]),
|
||||
{
|
||||
query: [
|
||||
PAGE_QUERY,
|
||||
SIZE_QUERY,
|
||||
{
|
||||
name: 'q',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'Up to 200 characters, matched in the name, the key or the base URL, ignoring case and accents.',
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
@@ -1257,7 +1279,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'author-list',
|
||||
'GET',
|
||||
'/v1/author/',
|
||||
'List the authors by name, each with its article count.',
|
||||
'List the authors by name, each with its article count. The total is also in the X-Total-Count header.',
|
||||
'Verified user',
|
||||
verified([e(400, 'The page, size or search is not valid.'), e(401, UNAUTHORIZED)]),
|
||||
json({
|
||||
|
||||
+18
-39
@@ -586,7 +586,6 @@ export const messages = {
|
||||
Accepted: 'Acceptée',
|
||||
Expired: 'Expirée',
|
||||
Owner: 'Propriétaire',
|
||||
Member: 'Membre',
|
||||
You: 'Vous',
|
||||
'Renew invite': 'Renouveler l’invitation',
|
||||
'Renewing…': 'Renouvellement…',
|
||||
@@ -608,17 +607,8 @@ export const messages = {
|
||||
'Your site reads published articles from RenewCMS. Share this guide with whoever builds it.':
|
||||
'Votre site lit les articles publiés dans RenewCMS. Partagez ce guide avec la personne qui le développe.',
|
||||
'Open the guide': 'Ouvrir le guide',
|
||||
'A guide for the developer who builds your public site.':
|
||||
'Un guide pour la personne qui développe votre site public.',
|
||||
"RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors. Your website fetches published articles from the two addresses below and displays them in its own design.":
|
||||
'RenewCMS stocke et publie vos articles sans les afficher aux visiteurs. Votre site récupère les articles publiés aux deux adresses ci-dessous et les affiche selon son propre design.',
|
||||
'Base URL': 'URL de base',
|
||||
'Public API URL': 'URL de l’API publique',
|
||||
'Lists every online article.': 'Liste tous les articles publiés.',
|
||||
'Returns one online article. Drafts and unknown ids both return 404.':
|
||||
'Renvoie un article publié. Les brouillons et les identifiants inconnus renvoient 404.',
|
||||
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.':
|
||||
'Les requêtes anonymes n’obtiennent que les articles publiés. Les brouillons restent privés jusqu’à leur publication.',
|
||||
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.":
|
||||
'Les navigateurs ne peuvent appeler l’API que depuis les origines autorisées par le paramètre CORS du serveur. Demandez à la personne qui gère le serveur d’ajouter l’adresse de votre site.',
|
||||
Response: 'Réponse',
|
||||
@@ -773,7 +763,6 @@ export const messages = {
|
||||
"You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection.":
|
||||
'Vous êtes hors ligne. Vous pouvez consulter les images chargées, mais l’importation, le renommage et la suppression nécessitent une connexion.',
|
||||
"You're offline": 'Vous êtes hors ligne',
|
||||
"You're back online": 'Vous êtes de nouveau en ligne',
|
||||
'{0} — account menu': '{0} — menu du compte',
|
||||
"You're logged in as {0}.": 'Vous êtes connecté en tant que {0}.',
|
||||
'Counted from the text of your articles each time you open this page.':
|
||||
@@ -794,8 +783,6 @@ export const messages = {
|
||||
"It's": 'Elle est',
|
||||
'All {0} are': 'Les {0} sont',
|
||||
'{0} used in {1}, {2} {3}': '{0} utilisées dans {1}, {2} {3}',
|
||||
'{0} {1} attention': '{0} {1} votre attention',
|
||||
'{0} {1} rejected': '{0} {1} refusées',
|
||||
'{0} {1} the same content.': '{0} {1} le même contenu.',
|
||||
'{0} · RenewCMS': '{0} · RenewCMS',
|
||||
Enter: 'Entrée',
|
||||
@@ -804,7 +791,6 @@ export const messages = {
|
||||
'Delete {0}?': 'Supprimer {0} ?',
|
||||
'Delete {0}': 'Supprimer {0}',
|
||||
'Used in {0}': 'Utilisée dans {0}',
|
||||
'Copy {0} example': 'Copier l’exemple {0}',
|
||||
'Drop to upload': 'Déposez pour importer',
|
||||
'Drop an image here, or choose a file': 'Déposez une image ici ou choisissez un fichier',
|
||||
'Send invite': 'Envoyer l’invitation',
|
||||
@@ -823,8 +809,6 @@ export const messages = {
|
||||
'Choose a new password': 'Choisir un nouveau mot de passe',
|
||||
'Changing password…': 'Modification du mot de passe…',
|
||||
'Change password': 'Modifier le mot de passe',
|
||||
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.':
|
||||
'Les images du contenu utilisent des chemins comme /static/uploadedImages/<uuid>.png. Ajoutez l’URL de base avant de les afficher.',
|
||||
selected: 'sélectionnées',
|
||||
you: 'vous',
|
||||
now: 'maintenant',
|
||||
@@ -847,13 +831,6 @@ export const messages = {
|
||||
'{0} files were rejected': '{0} fichiers ont été refusés',
|
||||
'{0} file needs attention': '{0} fichier nécessite votre attention',
|
||||
'{0} files need attention': '{0} fichiers nécessitent votre attention',
|
||||
'{0} has the same content.': '{0} a le même contenu.',
|
||||
'{0} have the same content.': '{0} ont le même contenu.',
|
||||
'{0} images · {1} selected': '{0} images · {1} sélectionnées',
|
||||
'image, {0} selected': 'image, {0} sélectionnée',
|
||||
'images, {0} selected': 'images, {0} sélectionnées',
|
||||
'Used in 1 article': 'Utilisée dans 1 article',
|
||||
'Used in {0} articles': 'Utilisée dans {0} articles',
|
||||
'API keys': 'Clés d’API',
|
||||
'Create key': 'Créer une clé',
|
||||
Key: 'Clé',
|
||||
@@ -1651,12 +1628,11 @@ export const messages = {
|
||||
'List keys': 'Lister les clés',
|
||||
'Create a key': 'Créer une clé',
|
||||
'Revoke a key': 'Révoquer une clé',
|
||||
'List one page of articles, newest update first, with the total and the tab counts. Anonymous callers get online articles only.':
|
||||
'Liste une page d’articles, du plus récemment modifié au plus ancien, avec le total et les compteurs des onglets. Les appels anonymes ne reçoivent que les articles publiés.',
|
||||
'List one page of articles, newest update first, with the total and the tab counts (all, online, draft and suggestions). Anonymous callers get online articles only.':
|
||||
'Liste une page d’articles, du plus récemment modifié au plus ancien, avec le total et les compteurs des onglets (tous, publiés, brouillons et suggestions). Les appels anonymes ne reçoivent que les articles publiés.',
|
||||
'Get one article. Drafts answer 404 to anonymous callers.':
|
||||
'Récupère un article. Les brouillons répondent 404 aux appels anonymes.',
|
||||
'Create a draft article.': 'Crée un article en brouillon.',
|
||||
'Update the body of an article.': 'Modifie le corps d’un article.',
|
||||
'Publish an article.': 'Publie un article.',
|
||||
'Unpublish an article.': 'Dépublie un article.',
|
||||
'Delete an article.': 'Supprime un article.',
|
||||
@@ -1666,7 +1642,6 @@ export const messages = {
|
||||
'Delete an image. Articles that use it show a broken image.':
|
||||
'Supprime une image. Les articles qui l’utilisent affichent une image cassée.',
|
||||
'Merge identical copies into one image.': 'Fusionne les copies identiques en une seule image.',
|
||||
'List the articles that use an image.': 'Liste les articles qui utilisent une image.',
|
||||
'Get the automatic duplicate purge settings.':
|
||||
'Récupère les paramètres de la purge automatique des doublons.',
|
||||
'Turn the automatic purge on or off, or change its interval.':
|
||||
@@ -1971,16 +1946,28 @@ export const messages = {
|
||||
'La requête ne peut pas être traitée car le format du corps ne correspond pas.',
|
||||
site_not_found: 'Site introuvable',
|
||||
'25, 50, 100 or 500. Default 100.': '25, 50, 100 ou 500. Par défaut 100.',
|
||||
'List the sites, the default first and then by name, each with its article count.':
|
||||
'Liste les sites, le site par défaut d’abord puis par nom, chacun avec son nombre d’articles.',
|
||||
'List the sites, the default first and then by name, each with its article count. Without page or size every matching site is returned; the number of matching sites is in the X-Total-Count header.':
|
||||
'Liste les sites, le site par défaut d’abord puis par nom, chacun avec son nombre d’articles. Sans page ni taille, tous les sites correspondants sont renvoyés ; leur nombre est dans l’en-tête X-Total-Count.',
|
||||
'Up to 200 characters, matched in the name, the key or the base URL, ignoring case and accents.':
|
||||
'Jusqu’à 200 caractères, recherchés dans le nom, la clé ou l’URL de base, sans tenir compte de la casse ni des accents.',
|
||||
'Log in. Made for the browser. A key is ignored here, with no 403.':
|
||||
'Se connecter. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Log out. Made for the browser. A key is ignored here, with no 403.':
|
||||
'Se déconnecter. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Register. Made for the browser. A key is ignored here, with no 403.':
|
||||
'S’inscrire. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Reset password. Made for the browser. A key is ignored here, with no 403.':
|
||||
'Réinitialiser le mot de passe. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Get one image, with the same fields as an item of the list.':
|
||||
'Récupère une image, avec les mêmes champs qu’un élément de la liste.',
|
||||
'You must be logged in to access this resource.':
|
||||
'Vous devez être connecté pour accéder à cette ressource.',
|
||||
'You must verify your account to access this resource.':
|
||||
'Vous devez vérifier votre compte pour accéder à cette ressource.',
|
||||
'The people credited on articles. Any verified user can read them; changes are made in the browser by an owner or admin.':
|
||||
'Les personnes créditées sur les articles. Tout utilisateur vérifié peut les lire ; les modifications se font dans le navigateur par un propriétaire ou un administrateur.',
|
||||
'List the authors by name, each with its article count.':
|
||||
'Liste les auteurs par nom, chacun avec son nombre d’articles.',
|
||||
'List the authors by name, each with its article count. The total is also in the X-Total-Count header.':
|
||||
'Liste les auteurs par nom, chacun avec son nombre d’articles. Le total est aussi dans l’en-tête X-Total-Count.',
|
||||
'The page, size or search is not valid.':
|
||||
'La page, la taille ou la recherche n’est pas valide.',
|
||||
'Up to 200 characters, matched in the name or the slug.':
|
||||
@@ -2247,14 +2234,6 @@ export const messages = {
|
||||
'Certaines images à fusionner ont changé. Actualisez et réessayez.',
|
||||
'List the articles, authors and sites that use an image, with the places where each one uses it.':
|
||||
'Liste les articles, auteurs et sites qui utilisent une image, avec les endroits où chacun l’utilise.',
|
||||
'Log in. Made for the browser. A key is ignored here, with no 403.':
|
||||
'Se connecter. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Log out. Made for the browser. A key is ignored here, with no 403.':
|
||||
'Se déconnecter. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Register. Made for the browser. A key is ignored here, with no 403.':
|
||||
'S’inscrire. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Reset password. Made for the browser. A key is ignored here, with no 403.':
|
||||
'Réinitialiser le mot de passe. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -6,6 +6,8 @@ import { messages } from './messages'
|
||||
|
||||
const SOURCES = [
|
||||
'components/ArticleCardList.vue',
|
||||
'components/ArticleListNotice.vue',
|
||||
'components/ArticleListToolbar.vue',
|
||||
'components/ArticleSiteFilter.vue',
|
||||
'components/ArticleTable.vue',
|
||||
'components/AuthorList.vue',
|
||||
@@ -13,13 +15,24 @@ const SOURCES = [
|
||||
'components/AuthorSelect.vue',
|
||||
'components/CreateAuthorDialog.vue',
|
||||
'components/CreateAuthorHint.vue',
|
||||
'components/EditorHeader.vue',
|
||||
'components/EditorMobileBar.vue',
|
||||
'components/EditorSkeleton.vue',
|
||||
'components/EmptyState.vue',
|
||||
'components/MetaSection.vue',
|
||||
'components/SeoAdvancedSection.vue',
|
||||
'components/SeoSocialSection.vue',
|
||||
'components/SiteSelect.vue',
|
||||
'composables/useArticleActions.ts',
|
||||
'composables/useArticleRowActions.ts',
|
||||
'composables/useEditorImages.ts',
|
||||
'composables/useEditorSeoPanel.ts',
|
||||
'composables/useMyAuthorProfile.ts',
|
||||
'composables/useSeoDismissals.ts',
|
||||
'composables/useSeoPanelCanonical.ts',
|
||||
'composables/useSeoPanelImages.ts',
|
||||
'composables/useSeoPanelSlug.ts',
|
||||
'composables/useUnsavedChangesGuard.ts',
|
||||
'lib/articleMeta.ts',
|
||||
'views/ArticleView.vue',
|
||||
'views/EditorView.vue',
|
||||
|
||||
@@ -9,21 +9,34 @@ import type { ResolvedSeo, SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
|
||||
|
||||
const SOURCES = [
|
||||
'components/ApiPreviewDialog.vue',
|
||||
'components/ArticleListNotice.vue',
|
||||
'components/ArticleListToolbar.vue',
|
||||
'components/EditorHeader.vue',
|
||||
'components/EditorMobileBar.vue',
|
||||
'components/EditorSkeleton.vue',
|
||||
'components/EmptyState.vue',
|
||||
'components/NeedsAttentionToggle.vue',
|
||||
'components/SeoStatusChip.vue',
|
||||
'views/ArticleView.vue',
|
||||
'components/FallbackChip.vue',
|
||||
'components/NeedsAttentionToggle.vue',
|
||||
'components/SearchPreview.vue',
|
||||
'components/SearchSharingSection.vue',
|
||||
'components/SeoAdvancedSection.vue',
|
||||
'components/SeoDisclosure.vue',
|
||||
'components/SeoPanel.vue',
|
||||
'components/SeoSocialSection.vue',
|
||||
'components/SeoStatusChip.vue',
|
||||
'components/SocialCardPreview.vue',
|
||||
'components/SoftCounter.vue',
|
||||
'components/SuggestionList.vue',
|
||||
'composables/useEditorSeoPanel.ts',
|
||||
'composables/useSeoDismissals.ts',
|
||||
'composables/useSeoPanelCanonical.ts',
|
||||
'composables/useSeoPanelImages.ts',
|
||||
'composables/useSeoPanelSlug.ts',
|
||||
'composables/useUnsavedChangesGuard.ts',
|
||||
'lib/seoApiPreview.ts',
|
||||
'lib/seoCopy.ts',
|
||||
'lib/seoPreview.ts',
|
||||
'views/ArticleView.vue',
|
||||
'views/EditorView.vue',
|
||||
]
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface StripCard {
|
||||
}
|
||||
|
||||
export const STRIP_KEYS: StripKey[] = ['total', 'active', 'failed', 'security']
|
||||
export const BAR_COUNT = 7
|
||||
export const MIN_BAR_HEIGHT = 2
|
||||
|
||||
function percent(part: number, whole: number) {
|
||||
|
||||
@@ -88,10 +88,6 @@ export function seoChanges(current: ArticleSeo, saved: ArticleSeo): SeoPatch {
|
||||
return changes as SeoPatch
|
||||
}
|
||||
|
||||
export function sameSeo(left: ArticleSeo, right: ArticleSeo) {
|
||||
return Object.keys(seoChanges(left, right)).length === 0
|
||||
}
|
||||
|
||||
export function charCount(text: string) {
|
||||
return [...text].length
|
||||
}
|
||||
|
||||
@@ -49,10 +49,6 @@ export function formatMegabytes(bytes: number) {
|
||||
: value.toFixed(1)
|
||||
}
|
||||
|
||||
export function isSizeRejectionBody(body: string) {
|
||||
return body.toLowerCase().includes('exceed')
|
||||
}
|
||||
|
||||
export function rejectionTitle(filename: string) {
|
||||
return t("\u0022{0}\u0022 can't be used", [filename])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
const DISABLED_FIELD = 'disabled:bg-elevated disabled:opacity-100'
|
||||
|
||||
export const fieldUi = {
|
||||
base: `h-11 text-lg text-ellipsis md:h-10 md:text-sm ${DISABLED_FIELD}`,
|
||||
}
|
||||
export const monoFieldUi = {
|
||||
base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${DISABLED_FIELD}`,
|
||||
}
|
||||
export const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD}` }
|
||||
@@ -7,10 +7,6 @@ export function cacheSize(bytes: number) {
|
||||
return bytes === 0 ? t('{0} MB', [0]) : formatVariantSize(bytes)
|
||||
}
|
||||
|
||||
export function filesCount(count: number) {
|
||||
return count === 1 ? t('1 file') : t('{0} files', [formatNumber(count)])
|
||||
}
|
||||
|
||||
export function filesAndSize(count: number, bytes: number) {
|
||||
return count === 1
|
||||
? t('1 file · {0}', [formatVariantSize(bytes)])
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
+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