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,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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user