feat: SEO SPA Sites, Articles and Integration (4/4)
This commit is contained in:
1 parent
cb737b7ae3
commit
02fb8751fd
47 files changed
+3218
-227
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
@@ -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')"
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user