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

+198
View File
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue'
import FallbackChip from '@/components/FallbackChip.vue'
import SegmentedTabs from '@/components/SegmentedTabs.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useResolvedSeo } from '@/composables/useResolvedSeo'
import { apiPreviewRows } from '@/lib/seoApiPreview'
type DialogView = 'formatted' | 'raw'
const SKELETON_WIDTHS = ['w-2/5', 'w-4/5', 'w-3/5', 'w-7/10', 'w-1/2', 'w-3/4']
const props = defineProps<{ articleId: number; isOnline: boolean }>()
const open = defineModel<boolean>('open', { default: false })
const view = ref<DialogView>('formatted')
const { phase, resolved, load } = useResolvedSeo(props.articleId)
const { copied, copy } = useCopyToClipboard()
const endpoint = computed(() => `GET /v1/article/${props.articleId}/seo`)
const views = computed(() => [
{ value: 'formatted' as const, label: t('Formatted') },
{ value: 'raw' as const, label: t('Raw JSON') },
])
const rows = computed(() => (resolved.value ? apiPreviewRows(resolved.value) : []))
const rawJson = computed(() => JSON.stringify(resolved.value, null, 2))
const copyLabel = computed(() => (copied.value ? t('Copied') : t('Copy JSON')))
const copyIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
watch(
open,
(isOpen) => {
if (isOpen) {
view.value = 'formatted'
load()
}
},
{ immediate: true },
)
async function copyRaw() {
try {
await copy(rawJson.value)
} catch {
return
}
}
</script>
<template>
<UModal
v-model:open="open"
:title="t('What the frontend gets')"
:description="
t(
'The SEO data returned by {0}, read only. RenewCMS doesn\'t render pages or add meta tags: your frontend does.',
[endpoint],
)
"
:close="{
'aria-label': t('Close'),
class: 'size-7 justify-center text-muted max-md:size-11',
}"
:ui="{
overlay: 'z-60',
content: `${SHEET_CONTENT_CLASS} z-60 sm:top-10 sm:max-w-180`,
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
title: 'text-lg/6 font-semibold',
description: 'mt-1 text-sm text-muted text-pretty max-md:pe-16',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: SHEET_FOOTER_CLASS,
}"
>
<template #body>
<div class="flex flex-col gap-3" data-testid="api-preview">
<UAlert
v-if="!isOnline"
color="info"
variant="soft"
icon="i-lucide-info"
:description="
t(
'This article is a draft. The API returns this data only to signed-in callers until it is published.',
)
"
:ui="{ root: 'items-start gap-2 px-3 py-2.5', description: 'text-toned' }"
data-testid="api-preview-draft"
/>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="w-50">
<SegmentedTabs v-model="view" :items="views" />
</div>
<UButton
color="neutral"
variant="outline"
:icon="copyIcon"
:disabled="phase !== 'ready'"
class="h-11 gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-4' }"
data-testid="api-preview-copy"
@click="copyRaw"
>
{{ copyLabel }}
</UButton>
<span class="sr-only" role="status" aria-live="polite">{{
copied ? t('Copied') : ''
}}</span>
</div>
<div
v-if="phase === 'error'"
class="flex flex-col gap-2"
data-testid="api-preview-error"
>
<UAlert
color="error"
variant="soft"
icon="i-lucide-circle-x"
role="alert"
:title="t('Couldn\'t load the SEO data')"
:description="
t('{0} failed. Your article is unchanged. Try again in a moment.', [
endpoint,
])
"
:actions="[
{
label: t('Try again'),
icon: 'i-lucide-rotate-cw',
color: 'neutral',
variant: 'outline',
class: 'h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:w-full max-md:justify-center md:h-7',
onClick: load,
},
]"
:ui="{
root: 'items-start gap-2.5 px-3.5 py-3',
title: 'font-semibold',
description: 'text-toned',
}"
/>
</div>
<div
v-else-if="phase !== 'ready'"
class="flex flex-col gap-3"
aria-busy="true"
data-testid="api-preview-loading"
>
<span
v-for="width in SKELETON_WIDTHS"
:key="width"
class="block h-3.5 bg-accented"
:class="width"
/>
</div>
<pre
v-else-if="view === 'raw'"
tabindex="0"
class="max-h-110 overflow-auto md:max-h-100 bg-elevated p-3 font-mono text-xs/4.5 text-highlighted"
data-testid="api-preview-raw"
>{{ rawJson }}</pre
>
<dl v-else class="flex flex-col" data-testid="api-preview-formatted">
<div
v-for="row in rows"
:key="row.key"
class="flex flex-col gap-0.5 border-b border-muted py-1.5 md:flex-row md:gap-3"
>
<dt class="shrink-0 font-mono text-xs/5 text-muted md:w-32.5">
{{ row.key }}
</dt>
<dd class="m-0 flex min-w-0 flex-1 flex-wrap items-center gap-2">
<span class="break-all text-highlighted">{{ row.value }}</span>
<FallbackChip v-if="row.chip" :label="row.chip" />
</dd>
</div>
</dl>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
color="neutral"
variant="outline"
block
class="h-11 justify-center text-sm text-highlighted sm:h-8 sm:w-auto sm:px-3"
data-testid="api-preview-close"
@click="open = false"
>
{{ t('Close') }}
</UButton>
</div>
</template>
</UModal>
</template>
+1 -1
View File
@@ -158,7 +158,7 @@ async function copyUrl() {
:ui="{
content:
'h-23/25 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
header: 'min-h-0 gap-3 px-4 pt-2 pb-0 sm:px-4',
header: 'min-h-13 items-center gap-3 border-b-0 px-4 pt-2 pb-0 sm:px-4',
title: 'text-lg/6 font-semibold',
description: 'sr-only',
body: 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pt-3 pb-4 sm:px-4',
+1
View File
@@ -124,6 +124,7 @@ const picker = computed(() => ({
description: t('The avatar for {0}.', [values.name || t('this author')]),
hint: t('Shown as a square. Choose an image with the face or logo near the middle.'),
confirmLabel: t('Use image'),
square: true,
}))
function retryUsers() {
+9 -3
View File
@@ -18,6 +18,8 @@ const props = withDefaults(
saving?: boolean
dirty?: boolean
readOnly?: boolean
wide?: boolean
submitDisabled?: boolean
notice?: DirectoryNotice | null
}>(),
{
@@ -25,6 +27,8 @@ const props = withDefaults(
saving: false,
dirty: false,
readOnly: false,
wide: false,
submitDisabled: false,
notice: null,
},
)
@@ -46,13 +50,15 @@ const closeButton = computed(() => ({
color: 'neutral' as const,
variant: 'ghost' as const,
'aria-label': t('Close'),
class: 'size-7 justify-center text-muted max-md:top-1.5 max-md:end-1.5 max-md:size-11',
class: 'size-7 justify-center text-muted max-md:top-4.5 max-md:end-1.5 max-md:size-11',
ui: { leadingIcon: 'size-4' },
}))
const overlayUi = computed(() => ({
content: isDesktop.value
? 'w-full max-w-120'
? props.wide
? 'w-full max-w-240'
: 'w-full max-w-120'
: 'h-23/25 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
header: 'h-14 min-h-14 gap-3 px-4 py-0 sm:px-4 md:px-5',
wrapper: 'min-w-0 flex-1',
@@ -185,7 +191,7 @@ function onOpenChange(isOpen: boolean) {
type="submit"
:form="formId"
:loading="saving"
:disabled="saving"
:disabled="saving || submitDisabled"
class="h-11 w-full justify-center px-4 text-sm md:h-8 md:w-auto"
>
{{ primaryLabel }}
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
defineProps<{ label: string }>()
</script>
<template>
<span
class="inline-flex h-5 max-w-full items-center gap-1 self-start border border-dashed border-accented px-1.5 font-mono text-2xs/4 font-medium whitespace-nowrap text-toned"
data-testid="fallback-chip"
>
<UIcon name="i-lucide-corner-down-right" class="size-2.5 shrink-0" />
<span class="truncate">{{ label }}</span>
</span>
</template>
+1
View File
@@ -814,6 +814,7 @@ onUnmounted(() => {
:in-article-ids="inArticleIds"
:selected="selected"
:plain="choose !== undefined"
:tile-ratio="choose && !choose.square ? 'landscape' : 'square'"
:hint="choose?.hint"
@search="searchLibrary"
@select="selectItem"
+3 -1
View File
@@ -17,8 +17,9 @@ const props = withDefaults(
avatarName?: string
square?: boolean
disabled?: boolean
autofocusChoose?: boolean
}>(),
{ help: '', avatarName: '', square: false, disabled: false },
{ help: '', avatarName: '', square: false, disabled: false, autofocusChoose: false },
)
const imageId = defineModel<number | null>({ default: null })
@@ -87,6 +88,7 @@ function remove() {
:disabled="disabled"
:class="BUTTON_CLASS"
:ui="{ leadingIcon: 'size-3.5' }"
:data-autofocus="autofocusChoose ? '' : undefined"
@click="pickerOpen = true"
>
{{ chooseLabel }}
+17 -4
View File
@@ -39,6 +39,7 @@ const props = defineProps<{
variants?: ImageVariant[]
variantsReady?: boolean
plain?: boolean
tileRatio?: 'square' | 'landscape'
hint?: string
}>()
@@ -50,6 +51,10 @@ const emit = defineEmits<{
upload: []
}>()
const tileAspect = computed(() =>
props.tileRatio === 'landscape' ? 'aspect-4/3' : 'aspect-square',
)
const alt = defineModel<string>('alt', { default: '' })
const variant = defineModel<string>('variant', { default: '' })
@@ -242,7 +247,7 @@ onBeforeUnmount(clearDebounce)
data-testid="picker-skeleton-tile"
class="flex flex-col gap-1.5"
>
<USkeleton class="aspect-square w-full rounded-none bg-accented" />
<USkeleton class="w-full rounded-none bg-accented" :class="tileAspect" />
<USkeleton class="h-2.5 w-[70%] rounded-none bg-accented" />
</div>
</div>
@@ -279,7 +284,8 @@ onBeforeUnmount(clearDebounce)
<MediaThumb
:item="item"
size="md"
class="aspect-square w-full border border-default"
class="w-full border border-default"
:class="tileAspect"
/>
<span
v-if="isInArticle(item)"
@@ -303,7 +309,10 @@ onBeforeUnmount(clearDebounce)
data-testid="picker-skeleton-tile"
class="flex flex-col gap-1.5"
>
<USkeleton class="aspect-square w-full rounded-none bg-accented" />
<USkeleton
class="w-full rounded-none bg-accented"
:class="tileAspect"
/>
<USkeleton class="h-2.5 w-[70%] rounded-none bg-accented" />
</div>
</template>
@@ -353,7 +362,11 @@ onBeforeUnmount(clearDebounce)
/>
<UFormField
:label="t('Alt text (optional)')"
help="Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration."
:help="
t(
'Describes the image for people who use a screen reader, and shows if it doesn\'t load. Leave it empty if the image is only decoration.',
)
"
:ui="{
label: 'font-medium text-highlighted',
help: 'text-xs/5 text-muted',
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import FallbackChip from '@/components/FallbackChip.vue'
import SegmentedTabs from '@/components/SegmentedTabs.vue'
import type { Site } from '@/composables/useSites'
import { hostOf } from '@/lib/directory'
import { chipLabel, descriptionChip } from '@/lib/seoCopy'
import {
PREVIEW_LIMITS,
siteDisplayName,
previewDescription,
previewDescriptionSource,
previewTitle,
previewTitleSource,
titleIsCut,
truncateText,
type PreviewDevice,
type SeoPreviewInput,
} from '@/lib/seoPreview'
const SLUG_PATH_LIMIT = 36
const props = defineProps<{
input: SeoPreviewInput
site: Site | undefined
slug: string
}>()
const device = defineModel<PreviewDevice>('device', { default: 'desktop' })
const items = computed(() => [
{ value: 'desktop' as const, label: t('Desktop') },
{ value: 'mobile' as const, label: t('Mobile') },
])
const fullTitle = computed(() => previewTitle(props.input))
const shownTitle = computed(() => truncateText(fullTitle.value, PREVIEW_LIMITS[device.value].title))
const shownDescription = computed(() =>
truncateText(previewDescription(props.input), PREVIEW_LIMITS[device.value].description),
)
const cutOff = computed(() => titleIsCut(fullTitle.value, device.value))
const siteName = computed(() => siteDisplayName(props.site))
const siteInitial = computed(() => [...siteName.value][0] ?? '')
const host = computed(() => hostOf(props.site?.base_url ?? ''))
const chips = computed(() => {
const titleChip =
previewTitleSource(props.input) === 'title' ? [chipLabel('article_title')] : []
const description = descriptionChip(previewDescriptionSource(props.input))
return [...titleChip, ...(description ? [description] : [])]
})
</script>
<template>
<div class="flex flex-col gap-2" data-testid="search-preview">
<div class="flex items-center justify-between gap-2">
<span class="font-semibold text-highlighted">{{ t('Search result') }}</span>
<div class="w-37.5">
<SegmentedTabs v-model="device" compact :items="items" />
</div>
</div>
<UCard
variant="outline"
:ui="{
root: 'rounded-none bg-default ring-0 border border-default divide-y-0 shadow-none',
body: 'flex flex-col gap-1 p-3.5 sm:p-3.5',
}"
:class="device === 'mobile' ? 'md:w-70' : ''"
data-testid="search-preview-card"
>
<div class="flex min-w-0 items-center gap-2">
<span
class="flex size-5.5 shrink-0 items-center justify-center rounded-full bg-elevated text-2xs/none font-semibold text-highlighted"
>{{ siteInitial }}</span
>
<div class="flex min-w-0 flex-col">
<span class="text-xs/4 text-highlighted">{{ siteName }}</span>
<span class="truncate text-2xs/3.5 text-muted"
>{{ host }} › {{ slug.slice(0, SLUG_PATH_LIMIT) }}</span
>
</div>
</div>
<span
class="mt-1 break-words text-info"
:class="device === 'desktop' ? 'text-lead/6' : 'text-lg/5.25'"
data-testid="search-preview-title"
>{{ shownTitle }}</span
>
<span
class="text-sm/4.75 break-words text-toned"
data-testid="search-preview-description"
>{{ shownDescription }}</span
>
</UCard>
<div v-if="chips.length > 0" class="flex flex-wrap gap-1.5">
<FallbackChip v-for="chip in chips" :key="chip" :label="chip" />
</div>
<span v-if="cutOff" class="text-xs text-muted" data-testid="search-preview-cut">{{
t('Cut off here, the way a search engine would.')
}}</span>
</div>
</template>
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, useId } from 'vue'
import SuggestionList from '@/components/SuggestionList.vue'
import { isChecking, type SeoCheckPhase } from '@/composables/useSeoCheck'
import type { SeoCheckStatus, SeoFocus, SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
const props = withDefaults(
defineProps<{
remembered: boolean | null
address: string
phase: SeoCheckPhase
status: SeoCheckStatus | null
suggestions: SeoSuggestion[]
sheet?: boolean
}>(),
{ sheet: false },
)
const emit = defineEmits<{
'update:remembered': [open: boolean]
edit: [focus?: SeoFocus]
preview: []
retry: []
go: [field: SuggestionField]
}>()
const bodyId = useId()
const isOpen = computed(() => props.remembered ?? false)
const checking = computed(() => isChecking(props.phase))
const hidden = computed(() => !checking.value && props.status === 'hidden')
const summary = computed(() => {
if (props.phase === 'failed') {
return t("Couldn't check this article.")
}
if (checking.value) {
return t('Checking…')
}
if (props.status === 'hidden') {
return t('Hidden from search')
}
if (props.suggestions.length === 1) {
return t('1 suggestion')
}
if (props.suggestions.length > 1) {
return t('{0} suggestions', [props.suggestions.length])
}
return t('Ready')
})
const warning = computed(
() => !checking.value && props.status !== 'hidden' && props.suggestions.length > 0,
)
const buttonClass = computed(
() =>
`${props.sheet ? 'h-11' : 'h-8'} w-full justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0`,
)
</script>
<template>
<section class="flex flex-col gap-3 border-b border-default pb-4" data-testid="seo-section">
<UCollapsible :open="isOpen" @update:open="emit('update:remembered', $event)">
<UButton
color="neutral"
variant="link"
class="min-h-6 w-full justify-start gap-2 p-0 text-sm font-semibold text-highlighted"
:aria-controls="bodyId"
data-testid="seo-toggle"
>
<UIcon
name="i-lucide-chevron-down"
class="size-3.5 shrink-0 transition-transform"
:class="{ '-rotate-90': !isOpen }"
/>
{{ t('Search and sharing') }}
</UButton>
<template #content>
<div :id="bodyId" class="mt-3 flex flex-col gap-3" data-testid="seo-body">
<div class="flex flex-col gap-1 bg-elevated p-2" data-testid="seo-address">
<span class="text-xs text-muted">{{ t('Address') }}</span>
<span class="font-mono text-xs/4.5 break-all text-highlighted">{{
address
}}</span>
</div>
<div v-if="hidden" class="flex gap-2 text-warning" data-testid="seo-hidden">
<UIcon name="i-lucide-eye-off" class="mt-0.5 size-3.5 shrink-0" />
<span class="text-pretty text-toned">{{
t('Hidden from search engines. This only sets a flag in the API.')
}}</span>
</div>
<SuggestionList
compact
:phase="phase"
:suggestions="suggestions"
@go="emit('go', $event)"
@more="emit('edit', 'suggestions')"
@retry="emit('retry')"
/>
<div class="flex flex-col gap-2">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-search"
:class="buttonClass"
:ui="{ leadingIcon: 'size-4' }"
data-testid="seo-edit"
@click="emit('edit')"
>
{{ t('Edit search and sharing') }}
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-braces"
:class="buttonClass"
:ui="{ leadingIcon: 'size-4' }"
data-testid="seo-preview"
@click="emit('preview')"
>
{{ t('What the frontend gets') }}
</UButton>
</div>
</div>
</template>
</UCollapsible>
<span
v-if="!isOpen"
class="truncate pl-5.5"
:class="warning ? 'text-warning' : 'text-muted'"
data-testid="seo-summary"
:title="summary"
>{{ summary }}</span
>
</section>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts" generic="V extends string">
import { SEGMENT_TAB_ACTIVE } from '@/lib/segmentStyles'
defineProps<{
items: { value: V; label: string }[]
modelValue: V
compact?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: V] }>()
</script>
<template>
<UTabs
:model-value="modelValue"
:items="items"
variant="pill"
color="neutral"
:content="false"
:ui="{
root: 'w-full',
list: 'gap-0 rounded-none border border-accented bg-default p-0',
indicator: 'hidden',
trigger: [
'flex-1 rounded-none px-0 font-normal text-toned',
SEGMENT_TAB_ACTIVE,
compact ? 'h-11 text-xs md:h-6' : 'h-11 text-sm md:h-8',
],
label: 'truncate',
}"
@update:model-value="emit('update:modelValue', String($event) as V)"
/>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { useId } from 'vue'
defineProps<{ title: string; tag?: string; summary: string }>()
const open = defineModel<boolean>('open', { default: false })
const bodyId = useId()
</script>
<template>
<UCollapsible
v-model:open="open"
:ui="{ root: 'flex flex-col gap-3 border border-default p-3' }"
>
<UButton
color="neutral"
variant="link"
class="min-h-11 w-full justify-start gap-2 p-0 text-sm text-highlighted md:min-h-6"
:aria-controls="bodyId"
data-testid="seo-disclosure-toggle"
>
<UIcon
name="i-lucide-chevron-down"
class="size-3.5 shrink-0 transition-transform"
:class="{ '-rotate-90': !open }"
/>
<span class="font-semibold">{{ title }}</span>
<span
v-if="tag"
class="inline-flex min-h-5 items-center border border-dashed border-accented px-1.5 py-px font-mono text-2xs/4 font-medium text-toned"
>{{ tag }}</span
>
<span class="min-w-0 flex-1 truncate text-right text-xs font-normal text-muted">{{
summary
}}</span>
</UButton>
<template #content>
<div :id="bodyId" class="flex flex-col gap-3">
<slot />
</div>
</template>
</UCollapsible>
</template>
+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>
+15 -3
View File
@@ -18,6 +18,10 @@ const props = withDefaults(
locked?: boolean
lockedHelp?: string
editLabel?: string
resetLabel?: string
placeholder?: string
autofocus?: boolean
maxlength?: number
}>(),
{
name: undefined,
@@ -28,6 +32,10 @@ const props = withDefaults(
locked: false,
lockedHelp: '',
editLabel: '',
resetLabel: '',
placeholder: '',
autofocus: false,
maxlength: undefined,
},
)
@@ -42,7 +50,7 @@ const emit = defineEmits<{
const readonly = computed(() => props.locked || props.mode === 'empty' || props.mode === 'auto')
const showAuto = computed(() => props.mode === 'auto' && !props.locked)
const inputUi = computed(() => ({
base: `h-11 font-mono text-base md:h-10 md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5' : ''}`,
base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5 pe-28 md:pe-24' : ''}`,
}))
const showReset = computed(
() => !props.locked && (props.mode === 'manual' || (props.mode === 'saved' && props.error)),
@@ -57,6 +65,8 @@ const shownHelp = computed(() => {
return props.help
})
const pencilLabel = computed(() => props.editLabel || t('Edit slug'))
const resetText = computed(() => props.resetLabel || t('Reset to match the name'))
const placeholderText = computed(() => props.placeholder || t('Generated from the name'))
</script>
<template>
@@ -65,7 +75,9 @@ const pencilLabel = computed(() => props.editLabel || t('Edit slug'))
<UInput
:model-value="value"
:readonly="readonly"
:placeholder="t('Generated from the name')"
:placeholder="placeholderText"
:maxlength="maxlength"
:data-autofocus="autofocus ? '' : undefined"
:color="error ? 'error' : undefined"
:highlight="mode === 'manual' && !error"
class="w-full"
@@ -120,7 +132,7 @@ const pencilLabel = computed(() => props.editLabel || t('Edit slug'))
:ui="{ leadingIcon: 'size-3' }"
@click="emit('reset')"
>
{{ t('Reset to match the name') }}
{{ resetText }}
</UButton>
</div>
</template>
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue'
import FallbackChip from '@/components/FallbackChip.vue'
import type { MediaItem } from '@/composables/useMedia'
import type { Site } from '@/composables/useSites'
import { hostOf } from '@/lib/directory'
import { chipLabel } from '@/lib/seoCopy'
import {
socialDescription,
socialTitle,
type SeoPreviewInput,
type SocialImageSource,
} from '@/lib/seoPreview'
const props = defineProps<{
input: SeoPreviewInput
site: Site | undefined
imageSource: SocialImageSource
imageName: string
imageItem: MediaItem | null
}>()
const imageFailed = ref(false)
watch(
() => props.imageItem?.url,
() => {
imageFailed.value = false
},
)
const host = computed(() => hostOf(props.site?.base_url ?? ''))
const title = computed(() => socialTitle(props.input))
const description = computed(() => socialDescription(props.input))
const chips = computed(() => {
const imageChips: Record<SocialImageSource, string | null> = {
social: null,
cover: chipLabel('cover_image'),
site: chipLabel('site_image'),
none: chipLabel('no_image'),
}
return [
props.input.seo.social_title ? null : chipLabel('meta_title'),
props.input.seo.social_description ? null : chipLabel('meta_description'),
imageChips[props.imageSource],
].filter((chip): chip is string => chip !== null)
})
</script>
<template>
<div class="flex flex-col gap-2" data-testid="social-card">
<span class="font-semibold text-highlighted">{{ t('Social card') }}</span>
<UCard
variant="outline"
:ui="{
root: 'rounded-none bg-default ring-0 border border-default divide-y-0 shadow-none',
body: 'p-0 sm:p-0',
}"
>
<div
class="social-card-image relative flex aspect-191/100 items-center justify-center overflow-hidden font-mono text-2xs text-muted"
data-testid="social-card-image"
>
<span v-if="imageSource !== 'none'">{{ imageName }}</span>
<img
v-if="imageItem && !imageFailed"
:src="imageItem.url"
alt=""
class="absolute inset-0 size-full object-cover"
@error="imageFailed = true"
/>
</div>
<div class="flex flex-col gap-0.5 border-t border-default px-3 py-2.5">
<span class="text-2xs/4 text-muted uppercase">{{ host }}</span>
<span
class="truncate font-semibold text-highlighted"
data-testid="social-card-title"
>{{ title }}</span
>
<span
class="line-clamp-2 text-xs/4.5 text-toned"
data-testid="social-card-description"
>{{ description }}</span
>
</div>
</UCard>
<div v-if="chips.length > 0" class="flex flex-wrap gap-1.5">
<FallbackChip v-for="chip in chips" :key="chip" :label="chip" />
</div>
</div>
</template>
<style scoped>
.social-card-image {
background:
repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 12px),
var(--rc-sand);
}
</style>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ value: number; min: number; max: number }>()
const outside = computed(
() => props.value > 0 && (props.value < props.min || props.value > props.max),
)
</script>
<template>
<span
class="font-mono text-xs"
:class="outside ? 'text-warning' : 'text-muted'"
data-testid="soft-counter"
>{{ value }} / {{ max }}</span
>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { isChecking, type SeoCheckPhase } from '@/composables/useSeoCheck'
import { suggestionLinkLabel, suggestionMessage } from '@/lib/seoCopy'
import type { SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
const COMPACT_LIMIT = 3
const SKELETON_WIDTHS = ['w-7/10', 'w-9/10', 'w-11/20']
const props = withDefaults(
defineProps<{
phase: SeoCheckPhase
suggestions: SeoSuggestion[]
compact?: boolean
}>(),
{ compact: false },
)
const emit = defineEmits<{
go: [field: SuggestionField]
more: []
retry: []
}>()
const shown = computed(() =>
props.compact ? props.suggestions.slice(0, COMPACT_LIMIT) : props.suggestions,
)
const hiddenCount = computed(() => props.suggestions.length - shown.value.length)
const checking = computed(() => isChecking(props.phase))
</script>
<template>
<div
class="flex flex-col gap-3 bg-elevated p-3"
:aria-busy="checking ? 'true' : undefined"
data-testid="suggestion-list"
>
<template v-if="phase === 'failed'">
<div class="flex items-start gap-2 text-toned">
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0 text-error" />
<span>{{ t("Couldn't check this article.") }}</span>
</div>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 self-start border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry')"
>
{{ t('Try again') }}
</UButton>
</template>
<template v-else-if="checking">
<div class="flex items-center gap-2 text-toned" role="status">
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
<span>{{ t('Checking this article…') }}</span>
</div>
<span
v-for="width in SKELETON_WIDTHS"
:key="width"
class="block h-3 bg-accented"
:class="width"
/>
</template>
<div v-else-if="suggestions.length === 0" class="flex items-start gap-2">
<UIcon name="i-lucide-circle-check" class="mt-0.5 size-3.5 shrink-0 text-success" />
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-highlighted">{{ t('All good') }}</span>
<span class="text-muted">{{ t('Nothing to suggest for this article.') }}</span>
</div>
</div>
<template v-else>
<div
v-for="suggestion in shown"
:key="suggestion.code"
class="flex items-start gap-2"
data-testid="suggestion"
>
<UIcon name="i-lucide-lightbulb" class="mt-0.5 size-3.5 shrink-0 text-warning" />
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="text-pretty text-highlighted">{{
suggestionMessage(suggestion)
}}</span>
<UButton
color="neutral"
variant="link"
icon="i-lucide-arrow-right"
class="min-h-11 gap-1 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
:ui="{ leadingIcon: 'size-3' }"
@click="emit('go', suggestion.field)"
>
{{ suggestionLinkLabel(suggestion.field) }}
</UButton>
</div>
</div>
<span
v-if="!compact"
class="text-xs/4.5 text-pretty text-muted"
data-testid="suggestions-note"
>{{ t('Suggestions never block saving or publishing.') }}</span
>
<UButton
v-if="hiddenCount > 0"
color="neutral"
variant="link"
class="min-h-11 self-start p-0 text-xs text-muted md:min-h-5"
data-testid="suggestions-more"
@click="emit('more')"
>
{{ t('+{0} more in Search and sharing', [hiddenCount]) }}
</UButton>
</template>
</div>
</template>