fix: technical debts left by the Sites, Authors and SEO slices, plus backend support for the new Sites and Authors pagination

This commit is contained in:
Florian Sylvain committed 2026-10-19 00:25:31 +02:00
1 parent 9f4ce426a8
commit daf3594316
86 files changed
+4223 -1485

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{
kind: 'range' | 'error'
failedPage: number
shownPage: number
}>()
defineEmits<{
retry: []
}>()
const isError = computed(() => props.kind === 'error')
const text = computed(() => {
if (props.kind === 'range') {
return t("That page doesn't exist. Showing the last page.")
}
return t("Couldn't load page {0}. Showing page {1}.", [props.failedPage, props.shownPage])
})
</script>
<template>
<UAlert
:role="isError ? 'alert' : 'status'"
:color="isError ? 'error' : 'info'"
variant="soft"
orientation="horizontal"
:close="false"
:icon="isError ? 'i-lucide-circle-x' : 'i-lucide-info'"
:description="text"
:ui="{
root: 'items-center gap-2.5 px-3.5 py-2.5',
icon: 'size-4',
description: 'text-toned',
}"
>
<template v-if="isError" #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="$emit('retry')"
>
{{ t('Retry') }}
</UButton>
</template>
</UAlert>
</template>
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ArticleSiteFilter from '@/components/ArticleSiteFilter.vue'
import NeedsAttentionToggle from '@/components/NeedsAttentionToggle.vue'
import type { ArticleCounts, ArticleStatus } from '@/composables/useArticles'
import type { Site } from '@/composables/useSites'
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
const props = defineProps<{
status: '' | ArticleStatus
counts: ArticleCounts
needsAttentionCount: number | null
needsAttention: boolean
site: string
sites: Site[]
severalSites: boolean
}>()
const emit = defineEmits<{
'update:status': [status: '' | ArticleStatus]
'update:needsAttention': [on: boolean]
'update:site': [site: string]
}>()
const search = defineModel<string>('search', { required: true })
const activeTab = computed<'' | ArticleStatus>({
get: () => props.status,
set: (value) => emit('update:status', value),
})
const tabItems = computed(() => [
{
get label() {
return t('All {0}', [props.counts.all])
},
value: '',
},
{
get label() {
return t('Online {0}', [props.counts.online])
},
value: 'online',
},
{
get label() {
return t('Drafts {0}', [props.counts.draft])
},
value: 'draft',
},
])
</script>
<template>
<div
class="flex flex-col-reverse gap-3 md:flex-row md:items-end md:gap-4 md:border-b md:border-default"
>
<UTabs
v-model="activeTab"
:items="tabItems"
variant="link"
color="neutral"
:content="false"
class="md:-mb-px md:flex-1"
:ui="{
list: 'gap-5 border-b-0 p-0 max-md:border-b',
trigger: `${UNDERLINE_TAB_TRIGGER} max-md:text-base`,
}"
/>
<div class="flex flex-col gap-3 md:flex-row md:items-end md:gap-4 md:pb-1.5">
<UInput
v-model="search"
type="search"
icon="i-lucide-search"
:placeholder="t('Search by title')"
class="w-full md:w-65"
:ui="{
base: 'h-11 text-lg md:h-8 md:text-sm',
leadingIcon: 'size-4 md:size-3.5',
}"
/>
<NeedsAttentionToggle
:model-value="needsAttention"
:count="needsAttentionCount"
class="md:-order-1"
@update:model-value="emit('update:needsAttention', $event)"
/>
<ArticleSiteFilter
v-if="severalSites"
:model-value="site"
:sites="sites"
@update:model-value="emit('update:site', $event)"
/>
</div>
</div>
</template>
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { t } from '@/i18n'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import PublishControl from '@/components/PublishControl.vue'
import SaveStatus from '@/components/SaveStatus.vue'
import type { Article } from '@/composables/useArticles'
import type { ArticleDraftState } from '@/composables/useArticleDraft'
defineProps<{
article: Article
state: ArticleDraftState
savedAt: Date | null
countText: string
saveEnabled: boolean
publishPending: string | undefined
publishDisabled: boolean
}>()
const emit = defineEmits<{
back: []
retry: []
save: []
publish: []
unpublish: []
delete: []
}>()
const previewOn = defineModel<boolean>('preview', { required: true })
</script>
<template>
<div
class="flex flex-shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-default bg-default px-4 py-3 lg:gap-3 lg:px-6 lg:py-3.5"
>
<UButton
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="hidden size-8 justify-center text-toned lg:inline-flex"
:aria-label="t('Back to Articles')"
@click="emit('back')"
/>
<div
class="flex min-w-0 flex-1 basis-full flex-wrap items-center gap-2.5 lg:min-w-50 lg:basis-50"
>
<h1
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
:title="t('The title can\'t be changed after creation.')"
>
{{ article.title }}
</h1>
<ArticleStatusBadge :online="article.is_online" size="lg" />
</div>
<SaveStatus :state="state" :saved-at="savedAt" @retry="emit('retry')" />
<span class="ml-auto font-mono text-xs text-muted lg:hidden">{{ countText }}</span>
<div class="hidden items-center gap-3 lg:flex">
<USwitch
v-model="previewOn"
:label="t('Preview')"
:ui="{
root: 'items-center gap-2',
container: 'h-auto',
base: 'h-4.5 w-8 p-0.5',
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-2.5',
wrapper: 'ms-0',
label: 'text-sm font-normal text-toned',
}"
/>
<UButton
data-testid="save-button"
icon="i-lucide-save"
color="neutral"
variant="outline"
class="h-8 gap-1.5 px-3"
:disabled="!saveEnabled"
@click="emit('save')"
>
{{ t('Save') }}
</UButton>
<PublishControl
class="h-8 gap-1.5 px-3"
:online="article.is_online"
:dirty="state === 'dirty'"
:pending="publishPending"
:disabled="publishDisabled"
@publish="emit('publish')"
@unpublish="emit('unpublish')"
/>
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
class="h-8 gap-1.5 px-3"
@click="emit('delete')"
>
{{ t('Delete') }}
</UButton>
</div>
</div>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { t } from '@/i18n'
import PublishControl from '@/components/PublishControl.vue'
import type { ArticleDraftState } from '@/composables/useArticleDraft'
defineProps<{
online: boolean
state: ArticleDraftState
saveEnabled: boolean
publishPending: string | undefined
publishDisabled: boolean
}>()
const emit = defineEmits<{
details: []
save: []
publish: []
unpublish: []
}>()
</script>
<template>
<div class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden">
<UButton
icon="i-lucide-panel-right"
color="neutral"
variant="outline"
class="size-11 justify-center"
:aria-label="t('Details')"
@click="emit('details')"
/>
<UButton
data-testid="save-button-mobile"
color="neutral"
variant="outline"
class="h-11 flex-1 justify-center"
:disabled="!saveEnabled"
@click="emit('save')"
>
{{ t('Save') }}
</UButton>
<PublishControl
class="h-11 flex-1 justify-center"
hide-icon
:online="online"
:dirty="state === 'dirty'"
:pending="publishPending"
:disabled="publishDisabled"
@publish="emit('publish')"
@unpublish="emit('unpublish')"
/>
</div>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { t } from '@/i18n'
const emit = defineEmits<{
back: []
}>()
</script>
<template>
<div aria-busy="true" class="flex min-h-0 flex-1 flex-col lg:flex-row">
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<div
class="flex flex-shrink-0 items-center gap-2.5 border-b border-default bg-default px-6 py-3.5"
>
<UButton
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
:aria-label="t('Back to Articles')"
@click="emit('back')"
/>
<USkeleton class="h-5 w-80 rounded-none" data-testid="skeleton-title" />
<USkeleton class="h-5.5 w-15 rounded-none" />
</div>
<div
class="min-h-0 max-w-168 flex-1 space-y-3.5 px-12 py-10"
data-testid="skeleton-content"
>
<USkeleton class="h-3.5 w-full rounded-none" />
<USkeleton class="h-3.5 w-11/12 rounded-none" />
<USkeleton class="h-3.5 w-4/5 rounded-none" />
<USkeleton class="mt-4.5 h-5.5 w-2/5 rounded-none" />
<USkeleton class="h-3.5 w-full rounded-none" />
<USkeleton class="h-3.5 w-3/4 rounded-none" />
</div>
</div>
<div
class="w-70 flex-shrink-0 space-y-2.5 border-default p-5 lg:border-l"
data-testid="skeleton-details"
>
<div class="flex flex-col gap-3 border-b border-default pb-4">
<div class="flex min-h-6 items-center gap-2 text-highlighted">
<UIcon name="i-lucide-chevron-down" class="size-3.5 -rotate-90" />
<span class="text-sm font-semibold">{{ t('Site and authors') }}</span>
</div>
<USkeleton class="ml-5.5 h-3 w-2/5 rounded-none" data-testid="skeleton-meta" />
</div>
<div class="flex flex-col gap-3 border-b border-default pb-4">
<div class="flex min-h-6 items-center gap-2 text-highlighted">
<UIcon name="i-lucide-chevron-down" class="size-3.5 -rotate-90" />
<span class="text-sm font-semibold">{{ t('Search and sharing') }}</span>
</div>
<USkeleton class="ml-5.5 h-3 w-2/5 rounded-none" data-testid="skeleton-seo" />
</div>
<USkeleton class="h-3 w-full rounded-none" />
<USkeleton class="h-3 w-full rounded-none" />
<USkeleton class="h-3 w-3/5 rounded-none" />
</div>
</div>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { t } from '@/i18n'
import DirectoryField from '@/components/DirectoryField.vue'
import FallbackChip from '@/components/FallbackChip.vue'
import SeoDisclosure from '@/components/SeoDisclosure.vue'
import { SEO_LIMITS } from '@/lib/articleSeo'
import { chipLabel } from '@/lib/seoCopy'
import { monoFieldUi } from '@/lib/seoFieldUi'
defineProps<{
canonicalUrl: string
placeholder: string
summary: string
error: string
}>()
const emit = defineEmits<{
type: [value: string]
leave: []
}>()
const open = defineModel<boolean>('open', { required: true })
</script>
<template>
<SeoDisclosure v-model:open="open" :title="t('Advanced')" :summary="summary">
<div class="flex flex-col gap-1.5">
<DirectoryField
:label="t('Canonical URL')"
optional
:error="error"
:help="t('Only change this if the same content lives at another address.')"
>
<UInput
:model-value="canonicalUrl"
:placeholder="placeholder"
:maxlength="SEO_LIMITS.canonical_url"
:color="error ? 'error' : undefined"
:aria-invalid="error ? 'true' : undefined"
inputmode="url"
autocomplete="off"
class="w-full"
:ui="monoFieldUi"
@update:model-value="emit('type', String($event))"
@focusout="emit('leave')"
/>
</DirectoryField>
<FallbackChip v-if="!canonicalUrl" :label="chipLabel('canonical')" />
</div>
</SeoDisclosure>
</template>
+75 -267
View File
@@ -1,41 +1,34 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, ref } 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 SeoAdvancedSection from '@/components/SeoAdvancedSection.vue'
import SeoSocialSection from '@/components/SeoSocialSection.vue'
import SlugField from '@/components/SlugField.vue'
import SocialCardPreview from '@/components/SocialCardPreview.vue'
import SoftCounter from '@/components/SoftCounter.vue'
import SuggestionList from '@/components/SuggestionList.vue'
import type { SeoCheckPhase } from '@/composables/useSeoCheck'
import type { MediaItem } from '@/composables/useMedia'
import { useMediaNames } from '@/composables/useMediaNames'
import { useSeoPanelCanonical } from '@/composables/useSeoPanelCanonical'
import { useSeoPanelImages } from '@/composables/useSeoPanelImages'
import { useSeoPanelSlug } from '@/composables/useSeoPanelSlug'
import type { Site } from '@/composables/useSites'
import {
SEO_LIMITS,
canonicalIsValid,
charCount,
normalizeSlug,
typedSlug,
type ArticleSeo,
} from '@/lib/articleSeo'
import { slugify } from '@/lib/directory'
import { SEO_LIMITS, charCount, type ArticleSeo } from '@/lib/articleSeo'
import { chipLabel, descriptionChip } from '@/lib/seoCopy'
import type { DirectoryNotice } from '@/lib/directoryTypes'
import { areaUi, fieldUi } from '@/lib/seoFieldUi'
import {
DESCRIPTION_RANGE,
TITLE_RANGE,
hasSocialOverrides,
siteDisplayName,
previewDescription,
previewDescriptionSource,
socialImageSource,
type PreviewDevice,
type SeoPreviewInput,
} from '@/lib/seoPreview'
@@ -86,18 +79,9 @@ 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))
@@ -107,66 +91,53 @@ const previewInput = computed<SeoPreviewInput>(() => ({
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]
function update(patch: Partial<ArticleSeo>, immediate = false) {
emit('update:modelValue', { ...props.modelValue, ...patch })
emit('check', immediate)
}
const {
siteImageId,
siteImageName,
coverItem,
socialItem,
cardSource,
cardImageItem,
cardImageName,
remember,
} = useSeoPanelImages(seo, () => props.site)
const {
displaySlug,
slugMode,
slugSuggestion,
slugError,
slugUrl,
typeSlug,
unlockSlug,
resetSlug,
useSuggestedSlug,
checkSlug,
} = useSeoPanelSlug({
seo,
articleTitle: () => props.articleTitle,
savedSlug: () => props.savedSlug,
site: () => props.site,
siteName,
slugTakenOnSave: () => props.slugTakenOnSave,
slugVerdict: () => props.slugVerdict,
checkedSlug: () => props.checkedSlug,
update,
requestCheck: () => emit('check', true),
})
const 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 { advancedOpen, advancedSummary, canonicalError, typeCanonical, canonicalBlocked } =
useSeoPanelCanonical({
seo,
rejected: () => props.canonicalRejected,
update,
})
const descriptionPlaceholder = computed(() => previewDescription(previewInput.value))
const descriptionChipLabel = computed(() =>
@@ -184,40 +155,11 @@ const coverHelp = computed(() => {
? 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)
}
const coverPicker = computed(() => ({
title: t('Choose a cover image'),
description: t('The cover for this article.'),
confirmLabel: t('Use image'),
}))
function typeText(
key:
@@ -232,56 +174,6 @@ function typeText(
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)
}
@@ -294,12 +186,6 @@ 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)
@@ -317,26 +203,6 @@ function submit() {
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:bg-elevated disabled:opacity-100'
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>
@@ -552,84 +418,26 @@ const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD
</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>
<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>
<SeoSocialSection
:seo="seo"
:article-title="articleTitle"
:description-placeholder="descriptionPlaceholder"
:social-item="socialItem"
:saving="saving"
@type="typeText"
@remember="remember"
@image="setSocialImage"
/>
<SeoDisclosure
<SeoAdvancedSection
v-model:open="advancedOpen"
:title="t('Advanced')"
:canonical-url="seo.canonical_url"
:placeholder="slugUrl"
: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>
:error="canonicalError"
@type="typeCanonical"
@leave="canonicalBlocked"
/>
<USwitch
:model-value="seo.noindex"
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import DirectoryField from '@/components/DirectoryField.vue'
import FallbackChip from '@/components/FallbackChip.vue'
import MediaField from '@/components/MediaField.vue'
import SeoDisclosure from '@/components/SeoDisclosure.vue'
import type { MediaItem } from '@/composables/useMedia'
import { SEO_LIMITS, type ArticleSeo } from '@/lib/articleSeo'
import { chipLabel } from '@/lib/seoCopy'
import { hasSocialOverrides } from '@/lib/seoPreview'
import { areaUi, fieldUi } from '@/lib/seoFieldUi'
const props = defineProps<{
seo: ArticleSeo
articleTitle: string
descriptionPlaceholder: string
socialItem: MediaItem | null
saving: boolean
}>()
const emit = defineEmits<{
type: [key: 'social_title' | 'social_description', value: string]
remember: [item: MediaItem | null]
image: [id: number | null]
}>()
const open = ref(false)
const picker = computed(() => ({
title: t('Choose a social image'),
description: t('The image shown when this article is shared.'),
confirmLabel: t('Use image'),
}))
const summary = computed(() => {
if (hasSocialOverrides(props.seo)) {
return t('Customised')
}
return open.value ? '' : t('Uses the meta values')
})
</script>
<template>
<SeoDisclosure
v-model:open="open"
:title="t('Social sharing')"
:tag="t('Optional')"
:summary="summary"
>
<div class="flex flex-col gap-1.5">
<DirectoryField :label="t('Social title')" optional>
<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="emit('type', '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="emit('type', '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="picker"
:disabled="saving"
@update:item="emit('remember', $event)"
@update:model-value="emit('image', $event)"
/>
</SeoDisclosure>
</template>