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:
1 parent
9f4ce426a8
commit
daf3594316
86 files changed
+4223
-1485
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user