488 lines
13 KiB
Vue
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>
|