feat: SEO SPA Sites, Articles and Integration (4/4)

This commit is contained in:
Florian Sylvain committed 2026-10-18 15:51:28 +02:00
1 parent cb737b7ae3
commit 02fb8751fd
47 files changed
+3218 -227

No files matched your search

+7
View File
@@ -3,10 +3,12 @@ import { t } from '@/i18n'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import SeoStatusChip from '@/components/SeoStatusChip.vue'
import AuthorStack from '@/components/AuthorStack.vue'
import { authorsSummary } from '@/lib/articleMeta'
import { formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
import { seoChipOf } from '@/lib/articleSeoStatus'
const props = withDefaults(
defineProps<{
@@ -137,6 +139,11 @@ function onRowClick(article: Article, event: MouseEvent) {
size="sm"
:pending="pendingFor(article)"
/>
<SeoStatusChip
v-if="seoChipOf(article)"
:chip="seoChipOf(article)!"
size="sm"
/>
<span :title="article.updated_at"
>{{ t('Updated') }}
{{ formatRelativeDate(article.updated_at, now) }}</span
+2 -2
View File
@@ -34,9 +34,9 @@ const selected = computed({
icon="i-lucide-globe"
:search-input="false"
:aria-label="t('Site')"
class="w-full sm:w-37.5"
class="w-full md:w-37.5"
:ui="{
base: `h-11 rounded-none text-lg ring-(--ui-border-accented) sm:h-8 sm:text-sm ${siteKey ? 'text-highlighted' : 'text-muted'}`,
base: `h-11 rounded-none text-lg ring-(--ui-border-accented) md:h-8 md:text-sm ${siteKey ? 'text-highlighted' : 'text-muted'}`,
leadingIcon: 'size-3.5',
trailingIcon: 'size-3.5 text-muted',
content: 'rounded-none shadow-(--shadow-popover)',
+18 -6
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { t } from '@/i18n'
withDefaults(
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
online: boolean
size?: 'sm' | 'md' | 'lg'
@@ -11,6 +13,9 @@ withDefaults(
size: 'md',
},
)
const themeSize = computed(() => (props.size === 'sm' ? 'md' : props.size))
const compact = computed(() => (props.size === 'sm' ? 'h-5 px-1.5' : undefined))
</script>
<template>
@@ -19,13 +24,20 @@ withDefaults(
color="neutral"
variant="outline"
icon="i-lucide-loader-circle"
:size="size"
:class="size === 'lg' ? undefined : 'px-2'"
:size="themeSize"
:class="[size === 'lg' ? undefined : 'px-2', compact]"
:ui="{ leadingIcon: 'animate-spin' }"
>
{{ pending === 'publish' ? t('Publishing…') : t('Unpublishing…') }}
</UBadge>
<UBadge v-else-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
<UBadge
v-else-if="online"
color="neutral"
variant="solid"
icon="i-lucide-globe"
:size="themeSize"
:class="compact"
>
{{ t('Online') }}
</UBadge>
<UBadge
@@ -33,8 +45,8 @@ withDefaults(
color="neutral"
variant="outline"
icon="i-lucide-pencil-line"
:size="size"
:class="size === 'lg' ? undefined : 'px-2'"
:size="themeSize"
:class="[size === 'lg' ? undefined : 'px-2', compact]"
>
{{ t('Draft') }}
</UBadge>
+54 -29
View File
@@ -4,10 +4,12 @@ import { t } from '@/i18n'
import { computed, ref, watchPostEffect } from 'vue'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import SeoStatusChip from '@/components/SeoStatusChip.vue'
import AuthorStack from '@/components/AuthorStack.vue'
import type { Site } from '@/composables/useSites'
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
import { seoChipOf } from '@/lib/articleSeoStatus'
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
const props = withDefaults(
@@ -20,7 +22,7 @@ const props = withDefaults(
sites?: Site[]
now?: Date
}>(),
{ skeletonRows: 5, sort: DEFAULT_ARTICLE_SORT, sites: () => [] },
{ skeletonRows: 6, sort: DEFAULT_ARTICLE_SORT, sites: () => [] },
)
const emit = defineEmits<{
@@ -38,6 +40,15 @@ const SINGLE_SITE_WIDTHS =
const SEVERAL_SITES_WIDTHS =
'[&:nth-child(2)]:w-26 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-27.5 [&:nth-child(5)]:w-32.5 [&:nth-child(6)]:w-35 [&:nth-child(7)]:w-11'
const rows = computed(() =>
props.loading
? Array.from(
{ length: props.skeletonRows },
(_, index) => ({ id: -(index + 1) }) as Article,
)
: props.articles,
)
const showSite = computed(() => props.sites.length > 1)
const columnWidths = computed(() => (showSite.value ? SEVERAL_SITES_WIDTHS : SINGLE_SITE_WIDTHS))
@@ -212,29 +223,22 @@ function actionsFor(article: Article) {
}
function onSelect(_event: Event, row: { original: Article }) {
if (props.loading) {
return
}
router.push(editPath(row.original))
}
</script>
<template>
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
<span role="status" class="sr-only">{{ t('Loading articles') }}</span>
<div
v-for="n in skeletonRows"
:key="n"
class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0"
>
<USkeleton class="h-3 max-w-[55%] flex-1 rounded-none bg-accented" />
<USkeleton class="h-6 w-15 rounded-none bg-accented" />
<USkeleton v-if="showSite" class="h-3 w-17.5 rounded-none bg-accented" />
<USkeleton class="h-5 w-15 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
</div>
</div>
<div v-else ref="tableRoot" class="hidden border border-default bg-default md:block">
<div
ref="tableRoot"
:aria-busy="loading ? 'true' : undefined"
class="hidden border border-default bg-default md:block"
>
<span v-if="loading" role="status" class="sr-only">{{ t('Loading articles') }}</span>
<UTable
:data="articles"
:data="rows"
:columns="columns"
:ui="{
base: 'table-fixed',
@@ -257,6 +261,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<template #title-header="{ column }">
<button
type="button"
:disabled="loading"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
@@ -273,6 +278,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<template #status-header="{ column }">
<button
type="button"
:disabled="loading"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
@@ -289,6 +295,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<template #created_at-header="{ column }">
<button
type="button"
:disabled="loading"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
@@ -305,6 +312,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<template #updated_at-header="{ column }">
<button
type="button"
:disabled="loading"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
@@ -319,7 +327,9 @@ function onSelect(_event: Event, row: { original: Article }) {
</template>
<template #title-cell="{ row }">
<USkeleton v-if="loading" class="h-3 w-3/5 rounded-none bg-accented" />
<RouterLink
v-else
:to="editPath(row.original)"
:data-row-title="row.original.id"
class="font-medium text-highlighted"
@@ -330,7 +340,8 @@ function onSelect(_event: Event, row: { original: Article }) {
</template>
<template #authors-cell="{ row }">
<span class="flex min-w-0 items-center" :title="authorNames(row.original)">
<USkeleton v-if="loading" class="h-6 w-15 rounded-none bg-accented" />
<span v-else class="flex min-w-0 items-center" :title="authorNames(row.original)">
<AuthorStack
v-if="row.original.authors?.length"
:authors="row.original.authors"
@@ -340,32 +351,46 @@ function onSelect(_event: Event, row: { original: Article }) {
</template>
<template #site-cell="{ row }">
<span class="block truncate text-toned">{{ siteNameOf(row.original) }}</span>
<USkeleton v-if="loading" class="h-3 w-17.5 rounded-none bg-accented" />
<span v-else class="block truncate text-toned">{{ siteNameOf(row.original) }}</span>
</template>
<template #status-cell="{ row }">
<ArticleStatusBadge
:online="row.original.is_online"
size="sm"
:pending="pendingFor(row.original)"
:aria-busy="isBusy(row.original) ? 'true' : undefined"
/>
<USkeleton v-if="loading" class="h-5 w-15 rounded-none bg-accented" />
<span v-else class="flex items-center gap-1.5">
<ArticleStatusBadge
:online="row.original.is_online"
size="md"
:pending="pendingFor(row.original)"
:aria-busy="isBusy(row.original) ? 'true' : undefined"
/>
<SeoStatusChip
v-if="seoChipOf(row.original)"
:chip="seoChipOf(row.original)!"
/>
</span>
</template>
<template #created_at-cell="{ row }">
<span class="text-muted">{{
<USkeleton v-if="loading" class="h-3 w-20 rounded-none bg-accented" />
<span v-else class="text-muted">{{
formatAbsoluteDateFromIso(row.original.created_at)
}}</span>
</template>
<template #updated_at-cell="{ row }">
<span class="text-muted" :title="row.original.updated_at">{{
<USkeleton v-if="loading" class="h-3 w-20 rounded-none bg-accented" />
<span v-else class="text-muted" :title="row.original.updated_at">{{
formatRelativeDate(row.original.updated_at, now)
}}</span>
</template>
<template #actions-cell="{ row }">
<UDropdownMenu :items="actionsFor(row.original)" :content="{ align: 'end' }">
<UDropdownMenu
v-if="!loading"
:items="actionsFor(row.original)"
:content="{ align: 'end' }"
>
<UButton
icon="i-lucide-ellipsis"
color="neutral"
+4 -2
View File
@@ -78,8 +78,10 @@ function focusOnOpen(event: Event) {
nextTick(() => target.focus())
}
function focusFirstInvalid() {
nextTick(() => form.value?.$el.querySelector<HTMLElement>(INVALID_SELECTOR)?.focus())
async function focusFirstInvalid() {
await new Promise((resolve) => setTimeout(resolve))
await nextTick()
form.value?.$el.querySelector<HTMLElement>(INVALID_SELECTOR)?.focus()
}
defineExpose({ focusFirstInvalid })
+20 -1
View File
@@ -6,7 +6,13 @@ import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
const props = defineProps<{
variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online' | 'no-site'
variant:
| 'no-articles'
| 'no-results'
| 'no-drafts'
| 'no-online'
| 'no-site'
| 'needs-attention'
query?: string
}>()
@@ -62,6 +68,19 @@ const variants = computed(() => ({
action: t('Show all sites'),
primary: false,
},
'needs-attention': {
icon: 'i-lucide-circle-check',
get title() {
return t('Nothing needs attention')
},
get description() {
return t(
'Every article has a clean check. Suggestions are optional, so this is just a bonus.',
)
},
action: t('Show all articles'),
primary: false,
},
'no-online': {
icon: 'i-lucide-globe',
get title() {
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{
count: number | null
}>()
const pressed = defineModel<boolean>({ default: false })
</script>
<template>
<UButton
role="switch"
:aria-checked="pressed"
color="neutral"
variant="outline"
icon="i-lucide-lightbulb"
data-testid="needs-attention-toggle"
class="h-11 shrink-0 justify-start gap-2 border px-2.5 text-md ring-0 md:h-8 md:text-sm"
:class="
pressed
? 'border-(--rc-sand) bg-(--rc-sand) font-medium text-(--rc-on-sand) hover:bg-(--rc-sand)'
: 'border-accented bg-default font-normal text-toned'
"
:ui="{ leadingIcon: 'size-3.5' }"
@click="pressed = !pressed"
>
{{ t('Needs attention') }}
<span v-if="count !== null" class="font-mono text-2xs font-medium">{{ count }}</span>
</UButton>
</template>
+5 -1
View File
@@ -13,9 +13,10 @@ const props = withDefaults(
phase: SeoCheckPhase
status: SeoCheckStatus | null
suggestions: SeoSuggestion[]
dismissedCount?: number
sheet?: boolean
}>(),
{ sheet: false },
{ sheet: false, dismissedCount: 0 },
)
const emit = defineEmits<{
@@ -24,6 +25,7 @@ const emit = defineEmits<{
preview: []
retry: []
go: [field: SuggestionField]
restore: []
}>()
const bodyId = useId()
@@ -93,6 +95,8 @@ const buttonClass = computed(
compact
:phase="phase"
:suggestions="suggestions"
:dismissed-count="dismissedCount"
@restore="emit('restore')"
@go="emit('go', $event)"
@more="emit('edit', 'suggestions')"
@retry="emit('retry')"
+9 -1
View File
@@ -39,7 +39,7 @@ import {
type PreviewDevice,
type SeoPreviewInput,
} from '@/lib/seoPreview'
import type { SeoFocus, SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
import type { SeoFocus, SeoSuggestion, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
const DESKTOP_QUERY = '(min-width: 48rem)'
const PANEL_FIELDS: SuggestionField[] = [
@@ -58,6 +58,8 @@ const props = defineProps<{
site: Site | undefined
phase: SeoCheckPhase
suggestions: SeoSuggestion[]
dismissedCount: number
dismissingCode: SuggestionCode | null
slugVerdict: { available: boolean; suggestion: string | null } | null
checkedSlug: string | undefined
slugTakenOnSave: string | null
@@ -74,6 +76,8 @@ const emit = defineEmits<{
close: []
preview: []
go: [field: SuggestionField]
dismiss: [code: SuggestionCode]
restore: []
}>()
const [DefineSuggestions, Suggestions] = createReusableTemplate()
@@ -349,6 +353,10 @@ const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD
<SuggestionList
:phase="phase"
:suggestions="suggestions"
:dismissed-count="dismissedCount"
:dismissing-code="dismissingCode"
@dismiss="emit('dismiss', $event)"
@restore="emit('restore')"
@go="goTo"
@retry="emit('check', true)"
/>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { SeoChip } from '@/lib/articleSeoStatus'
const props = withDefaults(
defineProps<{
chip: SeoChip
size?: 'sm' | 'md'
}>(),
{ size: 'md' },
)
const description = computed(() => {
if (props.chip.kind === 'hidden') {
return t('Hidden from search engines')
}
return props.chip.count === 1 ? t('1 suggestion') : t('{0} suggestions', [props.chip.count])
})
const appearance = computed(() =>
props.chip.kind === 'hidden'
? { icon: 'i-lucide-eye-off', class: 'border border-accented bg-transparent text-toned' }
: {
icon: 'i-lucide-lightbulb',
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
},
)
</script>
<template>
<UBadge
role="img"
color="neutral"
variant="soft"
:icon="appearance.icon"
:title="description"
:aria-label="description"
data-testid="seo-status-chip"
:class="appearance.class"
:ui="{
base: [
size === 'md' ? 'h-5.5' : 'h-5',
'shrink-0 gap-1 px-1.5 py-0 text-xs font-medium whitespace-nowrap',
],
leadingIcon: 'size-3',
}"
>
{{ chip.kind === 'hidden' ? t('Hidden') : chip.count }}
</UBadge>
</template>
+147 -2
View File
@@ -2,17 +2,25 @@
import { t } from '@/i18n'
import { computed, reactive, ref } from 'vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import DirectoryField from '@/components/DirectoryField.vue'
import DirectoryPanel from '@/components/DirectoryPanel.vue'
import MediaField from '@/components/MediaField.vue'
import SlugField from '@/components/SlugField.vue'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useSites, type Site, type SitePatch, type SiteRobots } from '@/composables/useSites'
import {
useSites,
type NewSite,
type Site,
type SitePatch,
type SiteRobots,
} from '@/composables/useSites'
import { useSlugField } from '@/composables/useSlugField'
import type { MediaItem } from '@/composables/useMedia'
import {
SITE_KEY_MAX,
canonicalPreview,
characterCount,
siteUrlMessage,
siteUrlProblem,
suggestFreeSlug,
@@ -20,6 +28,14 @@ import {
import { copy } from '@/lib/directoryCopy'
import { classifyFailure } from '@/lib/directoryErrors'
import { baseUrlProblem, nameProblem, siteKeyProblem } from '@/lib/directoryRules'
import {
DEFAULT_DESCRIPTION_MAX,
DEFAULT_TITLE_TEMPLATE,
SITE_NAME_MAX,
TITLE_TEMPLATE_MAX,
exampleTitle,
templateProblem,
} from '@/lib/siteTitleTemplate'
import type { DirectoryNotice } from '@/lib/directoryTypes'
const PREVIEW_FALLBACK_URL = 'https://journal.example.com'
@@ -47,6 +63,9 @@ const currentDefault = computed(() => props.sites.find((site) => site.is_default
const values = reactive({
name: props.site?.name ?? '',
baseUrl: props.site?.base_url ?? '',
siteName: props.site?.site_name ?? '',
template: props.site?.title_template ?? DEFAULT_TITLE_TEMPLATE,
description: props.site?.default_description ?? '',
imageId: props.site?.default_social_image_id ?? null,
robots: (props.site?.default_robots ?? 'index') as SiteRobots,
locale: props.site?.locale ?? '',
@@ -62,6 +81,7 @@ const errors = reactive({
suggestion: '',
url: '',
locale: '',
template: '',
})
const saving = ref(false)
const notice = ref<DirectoryNotice | null>(null)
@@ -72,6 +92,9 @@ function snapshot() {
values.name,
slug.value.value,
values.baseUrl,
values.siteName,
values.template,
values.description,
values.imageId,
values.robots,
values.locale,
@@ -102,12 +125,23 @@ const switchHelp = computed(() =>
currentDefault.value?.name ?? '',
]),
)
const example = computed(() => exampleTitle(values.template, values.siteName, values.name))
const descriptionCount = computed(() => characterCount(values.description))
const urlError = computed(() => errors.url)
const previewUrl = computed(() =>
values.baseUrl.trim() && !urlError.value && siteUrlProblem(values.baseUrl) === null
? canonicalPreview(values.baseUrl)
: canonicalPreview(PREVIEW_FALLBACK_URL),
)
const defaultsRows = computed(() => [
{ label: t('Title'), text: t('Article title, then the template.') },
{
label: t('Description'),
text: t("Used last, after the article's description, excerpt and body."),
},
{ label: t('Image'), text: t('Used when an article has no cover image.') },
{ label: t('Robots'), text: t('Used unless an article is hidden from search engines.') },
])
const picker = computed(() => ({
title: t('Choose an image'),
description: t('The default social image for {0}.', [values.name || t('this site')]),
@@ -190,11 +224,24 @@ function checkKeyShape() {
return errors.key === ''
}
function onTemplate(value: string) {
values.template = value
if (errors.template && templateProblem(value) === '') {
errors.template = ''
}
}
function checkTemplate() {
errors.template = templateProblem(values.template)
return errors.template === ''
}
function validate() {
const nameOk = checkName()
const keyOk = nameOk ? checkKeyShape() : true
const urlOk = checkUrl()
return nameOk && keyOk && urlOk
const templateOk = checkTemplate()
return nameOk && keyOk && urlOk && templateOk
}
function changedFields(site: Site) {
@@ -205,6 +252,15 @@ function changedFields(site: Site) {
if (values.baseUrl.trim() !== site.base_url) {
patch.base_url = values.baseUrl.trim()
}
if (values.siteName.trim() !== (site.site_name ?? '')) {
patch.site_name = values.siteName.trim()
}
if (values.template.trim() !== (site.title_template ?? DEFAULT_TITLE_TEMPLATE)) {
patch.title_template = values.template.trim()
}
if (values.description.trim() !== (site.default_description ?? '')) {
patch.default_description = values.description.trim()
}
if (values.robots !== site.default_robots) {
patch.default_robots = values.robots
}
@@ -217,6 +273,20 @@ function changedFields(site: Site) {
return patch
}
function newSeoFields() {
const fields: Pick<NewSite, 'site_name' | 'title_template' | 'default_description'> = {}
if (values.siteName.trim()) {
fields.site_name = values.siteName.trim()
}
if (values.template.trim() !== DEFAULT_TITLE_TEMPLATE) {
fields.title_template = values.template.trim()
}
if (values.description.trim()) {
fields.default_description = values.description.trim()
}
return fields
}
async function create() {
const created = await createSite({
name: values.name.trim(),
@@ -225,6 +295,7 @@ async function create() {
default_robots: values.robots,
...(values.locale.trim() ? { locale: values.locale.trim() } : {}),
...(values.imageId !== null ? { default_social_image_id: values.imageId } : {}),
...newSeoFields(),
is_default: values.makeDefault,
})
emit('saved', created)
@@ -257,6 +328,10 @@ function handleFailure(error: unknown) {
errors.url = siteUrlMessage('invalid')
return
}
if (kind === 'invalidTitleTemplate') {
errors.template = t('The template must contain %title%.')
return
}
if (kind === 'invalidLocale') {
errors.locale = t('Use a language tag like en or fr-CH.')
return
@@ -370,6 +445,69 @@ async function submit() {
:picker="picker"
/>
<DirectoryField
:label="t('Site name')"
:help="t('Shown in titles through the template, and sent as the Open Graph site name.')"
>
<UInput
:model-value="values.siteName"
:placeholder="t('e.g. Renew')"
:maxlength="SITE_NAME_MAX"
autocomplete="off"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
@update:model-value="values.siteName = String($event)"
/>
</DirectoryField>
<div class="flex min-w-0 flex-col gap-1.5">
<DirectoryField
:label="t('Title template')"
:error="errors.template"
:help="t('Use %title% for the article title and %site% for the site name.')"
>
<UInput
:model-value="values.template"
:color="errors.template ? 'error' : undefined"
:maxlength="TITLE_TEMPLATE_MAX"
autocomplete="off"
class="w-full"
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
@update:model-value="onTemplate(String($event))"
@blur="checkTemplate"
/>
</DirectoryField>
<div
v-if="!errors.template"
class="flex min-w-0 items-center gap-1.5 bg-elevated px-2.5 py-2"
>
<span class="shrink-0 text-xs text-muted">{{ t('Example') }}</span>
<span class="min-w-0 flex-1 truncate text-highlighted">{{ example }}</span>
</div>
</div>
<DirectoryField
:label="t('Default description')"
optional
:help="t('Used when an article has no description, excerpt or body text.')"
>
<template #hint>
<CharacterCounter
:value="descriptionCount"
:max="DEFAULT_DESCRIPTION_MAX"
variant="field"
/>
</template>
<UTextarea
:model-value="values.description"
:placeholder="t('One or two sentences about the site.')"
:maxlength="DEFAULT_DESCRIPTION_MAX"
class="w-full"
:ui="{ base: 'min-h-30 text-lg md:min-h-24 md:text-sm' }"
@update:model-value="values.description = String($event)"
/>
</DirectoryField>
<DirectoryField :label="t('Default robots policy')" :help="robotsHelp">
<URadioGroup
v-model="values.robots"
@@ -430,6 +568,13 @@ async function submit() {
t('Shown when the base URL is valid.')
}}</span>
</div>
<div class="flex flex-col gap-1.5 bg-elevated p-3">
<span class="text-xs text-muted">{{ t('How articles use these defaults') }}</span>
<div v-for="row in defaultsRows" :key="row.label" class="flex gap-2">
<span class="w-21 shrink-0 font-medium text-highlighted">{{ row.label }}</span>
<span class="text-pretty text-toned">{{ row.text }}</span>
</div>
</div>
</template>
</DirectoryPanel>
</template>
+103 -6
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { isChecking, type SeoCheckPhase } from '@/composables/useSeoCheck'
import { suggestionLinkLabel, suggestionMessage } from '@/lib/seoCopy'
import type { SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
import type { SeoSuggestion, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
const COMPACT_LIMIT = 3
const SKELETON_WIDTHS = ['w-7/10', 'w-9/10', 'w-11/20']
@@ -14,14 +14,18 @@ const props = withDefaults(
phase: SeoCheckPhase
suggestions: SeoSuggestion[]
compact?: boolean
dismissedCount?: number
dismissingCode?: SuggestionCode | null
}>(),
{ compact: false },
{ compact: false, dismissedCount: 0, dismissingCode: null },
)
const emit = defineEmits<{
go: [field: SuggestionField]
more: []
retry: []
dismiss: [code: SuggestionCode]
restore: []
}>()
const shown = computed(() =>
@@ -29,10 +33,57 @@ const shown = computed(() =>
)
const hiddenCount = computed(() => props.suggestions.length - shown.value.length)
const checking = computed(() => isChecking(props.phase))
const dismissedText = computed(() =>
props.dismissedCount === 1
? t('1 dismissed suggestion is hidden.')
: t('{0} dismissed suggestions are hidden.', [props.dismissedCount]),
)
const root = ref<HTMLElement | null>(null)
let removedCode: SuggestionCode | null = null
let removedIndex = 0
function dismiss(code: SuggestionCode, index: number) {
removedCode = code
removedIndex = index
emit('dismiss', code)
}
async function focusAfterRemoval() {
await nextTick()
const buttons = root.value?.querySelectorAll<HTMLElement>('[data-testid="suggestion-dismiss"]')
if (buttons && buttons.length > 0) {
buttons[Math.min(removedIndex, buttons.length - 1)]?.focus()
return
}
root.value?.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')?.focus()
}
watch(
() => props.suggestions.map((suggestion) => suggestion.code),
(codes) => {
if (removedCode !== null && !codes.includes(removedCode)) {
removedCode = null
focusAfterRemoval()
}
},
{ flush: 'post' },
)
watch(
() => props.dismissingCode,
(code) => {
if (code === null && removedCode !== null) {
removedCode = null
focusAfterRemoval()
}
},
{ flush: 'post' },
)
</script>
<template>
<div
ref="root"
class="flex flex-col gap-3 bg-elevated p-3"
:aria-busy="checking ? 'true' : undefined"
data-testid="suggestion-list"
@@ -69,12 +120,24 @@ const checking = computed(() => isChecking(props.phase))
<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>
<span class="text-muted">{{
dismissedCount > 0 ? dismissedText : t('Nothing to suggest for this article.')
}}</span>
<UButton
v-if="dismissedCount > 0"
color="neutral"
variant="link"
class="min-h-11 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
data-testid="suggestions-restore"
@click="emit('restore')"
>
{{ t('Show dismissed') }}
</UButton>
</div>
</div>
<template v-else>
<div
v-for="suggestion in shown"
v-for="(suggestion, index) in shown"
:key="suggestion.code"
class="flex items-start gap-2"
data-testid="suggestion"
@@ -95,13 +158,47 @@ const checking = computed(() => isChecking(props.phase))
{{ suggestionLinkLabel(suggestion.field) }}
</UButton>
</div>
<UButton
v-if="!compact"
color="neutral"
variant="ghost"
icon="i-lucide-x"
:loading="dismissingCode === suggestion.code"
:disabled="dismissingCode !== null"
:aria-busy="dismissingCode === suggestion.code ? 'true' : undefined"
:aria-label="t('Dismiss suggestion: {0}', [suggestionMessage(suggestion)])"
class="size-11 shrink-0 justify-center p-0 text-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary md:size-6"
:ui="{ leadingIcon: 'size-3.5' }"
data-testid="suggestion-dismiss"
@click="dismiss(suggestion.code, index)"
/>
</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
>{{
t(
'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.',
)
}}</span
>
<div
v-if="!compact && dismissedCount > 0"
class="flex flex-col gap-0.5"
data-testid="suggestions-dismissed"
>
<span class="text-xs/4.5 text-muted">{{ dismissedText }}</span>
<UButton
color="neutral"
variant="link"
class="min-h-11 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
data-testid="suggestions-restore"
@click="emit('restore')"
>
{{ t('Show dismissed') }}
</UButton>
</div>
<UButton
v-if="hiddenCount > 0"
color="neutral"