feat: SEO SPA editor integration

This commit is contained in:
Florian Sylvain committed 2026-10-16 03:06:57 +02:00
1 parent eb9876fde3
commit 470584400f
62 files changed
+7471 -58

No files matched your search

+679
View File
@@ -0,0 +1,679 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onMounted, ref, watch } 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 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 type { Site } from '@/composables/useSites'
import {
SEO_LIMITS,
canonicalIsValid,
charCount,
normalizeSlug,
typedSlug,
type ArticleSeo,
} from '@/lib/articleSeo'
import { slugify } from '@/lib/directory'
import { chipLabel, descriptionChip } from '@/lib/seoCopy'
import type { DirectoryNotice } from '@/lib/directoryTypes'
import {
DESCRIPTION_RANGE,
SOCIAL_TITLE_RANGE,
TITLE_RANGE,
hasSocialOverrides,
siteDisplayName,
previewDescription,
previewDescriptionSource,
socialImageSource,
type PreviewDevice,
type SeoPreviewInput,
} from '@/lib/seoPreview'
import type { SeoFocus, SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
const DESKTOP_QUERY = '(min-width: 48rem)'
const PANEL_FIELDS: SuggestionField[] = [
'seo_title',
'seo_description',
'cover_image_alt',
'cover_image_id',
'slug',
]
const props = defineProps<{
modelValue: ArticleSeo
savedSlug: string
articleTitle: string
body: string
site: Site | undefined
phase: SeoCheckPhase
suggestions: SeoSuggestion[]
slugVerdict: { available: boolean; suggestion: string | null } | null
checkedSlug: string | undefined
slugTakenOnSave: string | null
canonicalRejected: boolean
saving: boolean
notice: DirectoryNotice | null
focus: SeoFocus | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: ArticleSeo]
check: [immediate: boolean]
submit: []
close: []
preview: []
go: [field: SuggestionField]
}>()
const [DefineSuggestions, Suggestions] = createReusableTemplate()
const [DefineFields, Fields] = createReusableTemplate()
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))
const previewInput = computed<SeoPreviewInput>(() => ({
seo: seo.value,
title: props.articleTitle,
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]
})
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 descriptionPlaceholder = computed(() => previewDescription(previewInput.value))
const descriptionChipLabel = computed(() =>
descriptionChip(previewDescriptionSource(previewInput.value)),
)
const excerptTooLong = computed(() => charCount(seo.value.excerpt) > SEO_LIMITS.excerpt)
const excerptError = computed(() =>
excerptTooLong.value ? t('Shorten the excerpt to 300 characters or fewer.') : '',
)
const coverHelp = computed(() => {
if (seo.value.cover_image_id !== null || siteImageId.value === null) {
return ''
}
return siteImageName.value
? 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)
}
function typeText(
key:
| 'seo_title'
| 'seo_description'
| 'excerpt'
| 'cover_image_alt'
| 'social_title'
| 'social_description',
value: string,
) {
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)
}
function setSocialImage(id: number | null) {
update({ social_image_id: id }, true)
}
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)
return
}
focusTarget.value = field
nextTick(() => layout.value?.querySelector<HTMLElement>('[data-autofocus]')?.focus())
}
function submit() {
if (excerptTooLong.value) {
return
}
if (!canonicalBlocked()) {
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:opacity-40'
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>
<DefineSuggestions>
<div
class="flex flex-col gap-2 focus:outline-none"
:data-autofocus="focusTarget === 'suggestions' ? '' : undefined"
:tabindex="focusTarget === 'suggestions' ? -1 : undefined"
data-testid="seo-panel-suggestions"
>
<span v-if="isDesktop" class="font-semibold text-highlighted">{{
t('Suggestions')
}}</span>
<SuggestionList
:phase="phase"
:suggestions="suggestions"
@go="goTo"
@retry="emit('check', true)"
/>
</div>
</DefineSuggestions>
<DefinePreviewButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-braces"
class="h-11 gap-1.5 self-start border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:w-full max-md:justify-center max-md:text-sm md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
data-testid="seo-panel-preview"
@click="emit('preview')"
>
{{ t('What the frontend gets') }}
</UButton>
</DefinePreviewButton>
<DefinePreviews>
<SearchPreview
v-model:device="device"
:input="previewInput"
:site="site"
:slug="displaySlug"
/>
<SocialCardPreview
:input="previewInput"
:site="site"
:image-source="cardSource"
:image-name="cardImageName"
:image-item="cardImageItem"
/>
</DefinePreviews>
<DefineFields>
<div class="flex min-w-0 flex-col gap-4.5">
<div class="flex flex-col gap-2">
<SlugField
:label="t('URL slug')"
:mode="slugMode"
:value="displaySlug"
:error="slugError"
:suggestion="slugSuggestion"
:help="
t(
'Generated from the title until you edit it. Lowercase letters, numbers and hyphens. Unique on {0}.',
[siteName],
)
"
:manual-help="
t('Lowercase letters, numbers and hyphens. Unique on {0}.', [siteName])
"
:reset-label="t('Reset to match the title')"
:placeholder="t('Generated from the title')"
:edit-label="t('Edit slug')"
:maxlength="SEO_LIMITS.slug"
:autofocus="focusTarget === 'slug'"
@update:value="typeSlug"
@unlock="unlockSlug"
@reset="resetSlug"
@suggest="useSuggestedSlug"
@blur="checkSlug"
/>
<div class="flex min-w-0 items-center gap-1.5 bg-elevated px-2.5 py-2">
<UIcon name="i-lucide-link" class="size-3 shrink-0 text-muted" />
<span class="font-mono text-xs/4.5 break-all text-highlighted">{{
slugUrl
}}</span>
</div>
</div>
<div class="flex flex-col gap-1.5">
<DirectoryField
:label="t('Meta title')"
optional
:help="t('Recommended 30 to 60 characters. This is a guide, not a limit.')"
>
<template #hint>
<SoftCounter
:value="charCount(seo.seo_title)"
:min="TITLE_RANGE.min"
:max="TITLE_RANGE.max"
/>
</template>
<UInput
:model-value="seo.seo_title"
:placeholder="articleTitle"
:maxlength="SEO_LIMITS.seo_title"
:data-autofocus="focusTarget === 'seo_title' ? '' : undefined"
autocomplete="off"
class="w-full"
:ui="fieldUi"
@update:model-value="typeText('seo_title', String($event))"
/>
</DirectoryField>
<FallbackChip v-if="!seo.seo_title" :label="chipLabel('article_title')" />
</div>
<div class="flex flex-col gap-1.5">
<DirectoryField
:label="t('Meta description')"
optional
:help="t('Recommended 70 to 160 characters.')"
>
<template #hint>
<SoftCounter
:value="charCount(seo.seo_description)"
:min="DESCRIPTION_RANGE.min"
:max="DESCRIPTION_RANGE.max"
/>
</template>
<UTextarea
:model-value="seo.seo_description"
:placeholder="descriptionPlaceholder"
:maxlength="SEO_LIMITS.seo_description"
:data-autofocus="focusTarget === 'seo_description' ? '' : undefined"
:rows="3"
class="w-full"
:ui="areaUi"
@update:model-value="typeText('seo_description', String($event))"
/>
</DirectoryField>
<FallbackChip
v-if="descriptionChipLabel && !seo.seo_description"
:label="descriptionChipLabel"
/>
</div>
<DirectoryField
:label="t('Excerpt')"
optional
:error="excerptError"
:help="
t(
'Used as the description when there is none, and as the teaser for the frontend.',
)
"
>
<template #hint>
<span
class="font-mono text-xs"
:class="excerptTooLong ? 'text-error' : 'text-muted'"
data-testid="excerpt-counter"
>{{ charCount(seo.excerpt) }} / {{ SEO_LIMITS.excerpt }}</span
>
</template>
<UTextarea
:model-value="seo.excerpt"
:placeholder="t('A short summary. It is also sent as the article teaser.')"
:color="excerptTooLong ? 'error' : undefined"
:rows="3"
class="w-full"
:ui="areaUi"
@update:model-value="typeText('excerpt', String($event))"
/>
</DirectoryField>
<div class="flex flex-col gap-2.5">
<MediaField
:model-value="seo.cover_image_id"
:item="coverItem"
:label="t('Cover image')"
:help="coverHelp"
:picker="coverPicker"
:disabled="saving"
:autofocus-choose="focusTarget === 'cover_image_id'"
@update:item="remember"
@update:model-value="setCover"
/>
<DirectoryField
v-if="seo.cover_image_id !== null"
:label="t('Alt text')"
:help="
seo.cover_image_alt
? ''
: t(
'Needed before the cover counts as ready. Sent as image alt in the API.',
)
"
>
<UInput
:model-value="seo.cover_image_alt"
:placeholder="t('Describe the image for people who can\'t see it')"
:maxlength="SEO_LIMITS.cover_image_alt"
:data-autofocus="focusTarget === 'cover_image_alt' ? '' : undefined"
autocomplete="off"
class="w-full"
:ui="fieldUi"
@update:model-value="typeText('cover_image_alt', String($event))"
/>
</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>
<template #hint>
<SoftCounter
:value="charCount(seo.social_title)"
:min="SOCIAL_TITLE_RANGE.min"
:max="SOCIAL_TITLE_RANGE.max"
/>
</template>
<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>
<SeoDisclosure
v-model:open="advancedOpen"
:title="t('Advanced')"
: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>
<USwitch
:model-value="seo.noindex"
:label="t('Hide from search engines')"
:description="
t('Sets noindex in the API. It doesn\'t change who can read the article.')
"
:ui="{
root: 'items-start gap-3',
base: 'mt-0.5 h-4.5 w-8 data-[disabled]:opacity-40',
thumb: 'size-3.5',
label: 'font-medium text-highlighted',
description: 'text-xs/4.5 text-pretty text-muted',
}"
@update:model-value="setNoindex(Boolean($event))"
/>
</div>
</DefineFields>
<DirectoryPanel
wide
:title="t('Search and sharing')"
:submit-label="t('Save changes')"
:saving="saving"
:submit-disabled="excerptTooLong"
:notice="notice"
@close="emit('close')"
@submit="submit"
>
<div v-if="isDesktop" ref="layout" class="flex items-start gap-7">
<div class="min-w-0 flex-1">
<Fields />
</div>
<div class="sticky top-0 flex w-85 shrink-0 flex-col gap-4.5">
<Previews />
<Suggestions />
<PreviewButton />
</div>
</div>
<div v-else ref="layout" class="flex flex-col gap-4.5">
<Suggestions />
<Fields />
<Previews />
<PreviewButton />
</div>
</DirectoryPanel>
</template>