feat: SEO SPA editor integration
This commit is contained in:
1 parent
eb9876fde3
commit
470584400f
62 files changed
+7471
-58
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user