Files
RenewCMS/web/src/components/SeoPanel.vue
T

488 lines
13 KiB
Vue

<script setup lang="ts">
import AppSwitch from '@/components/AppSwitch.vue'
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import { createReusableTemplate } 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 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 { useSeoPanelCanonical } from '@/composables/useSeoPanelCanonical'
import { useSeoPanelImages } from '@/composables/useSeoPanelImages'
import { useSeoPanelSlug } from '@/composables/useSeoPanelSlug'
import type { Site } from '@/composables/useSites'
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,
siteDisplayName,
previewDescription,
previewDescriptionSource,
type PreviewDevice,
type SeoPreviewInput,
} from '@/lib/seoPreview'
import type { SeoFocus, SeoSuggestion, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
import { useIsDesktop } from '@/composables/useIsDesktop'
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[]
dismissedCount: number
dismissingCode: SuggestionCode | null
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]
dismiss: [code: SuggestionCode]
restore: []
}>()
const [DefineSuggestions, Suggestions] = createReusableTemplate()
const [DefineFields, Fields] = createReusableTemplate()
const [DefinePreviews, Previews] = createReusableTemplate()
const [DefinePreviewButton, PreviewButton] = createReusableTemplate()
const isDesktop = useIsDesktop()
const layout = ref<HTMLElement | null>(null)
const focusTarget = ref<SeoFocus | null>(props.focus)
const device = ref<PreviewDevice>('desktop')
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,
}))
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 { advancedOpen, advancedSummary, canonicalError, typeCanonical, canonicalBlocked } =
useSeoPanelCanonical({
seo,
rejected: () => props.canonicalRejected,
update,
})
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(() => ({
title: t('Choose a cover image'),
description: t('The cover for this article.'),
confirmLabel: t('Use image'),
}))
function typeText(
key:
| 'seo_title'
| 'seo_description'
| 'excerpt'
| 'cover_image_alt'
| 'social_title'
| 'social_description',
value: string,
) {
update({ [key]: value })
}
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 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')
}
}
</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"
:dismissed-count="dismissedCount"
:dismissing-code="dismissingCode"
@dismiss="emit('dismiss', $event)"
@restore="emit('restore')"
@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>
<SeoSocialSection
:seo="seo"
:article-title="articleTitle"
:description-placeholder="descriptionPlaceholder"
:social-item="socialItem"
:saving="saving"
@type="typeText"
@remember="remember"
@image="setSocialImage"
/>
<SeoAdvancedSection
v-model:open="advancedOpen"
:canonical-url="seo.canonical_url"
:placeholder="slugUrl"
:summary="advancedSummary"
:error="canonicalError"
@type="typeCanonical"
@leave="canonicalBlocked"
/>
<AppSwitch
: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',
container: 'mt-0.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>