553 lines
14 KiB
Vue
553 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
|
|
import { computed, reactive, ref } from 'vue'
|
|
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
|
import CharacterCounter from '@/components/CharacterCounter.vue'
|
|
import ConfirmDialog from '@/components/ConfirmDialog.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 SocialLinksRepeater from '@/components/SocialLinksRepeater.vue'
|
|
import UserSelect from '@/components/UserSelect.vue'
|
|
import {
|
|
useAuthors,
|
|
type Author,
|
|
type AuthorPatch,
|
|
type SocialLink,
|
|
} from '@/composables/useAuthors'
|
|
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
|
import { useSlugField } from '@/composables/useSlugField'
|
|
import type { MediaItem } from '@/composables/useMedia'
|
|
import { useLocalizedOverlay } from '@/i18n/presentation'
|
|
import { AUTHOR_SLUG_MAX, characterCount, suggestFreeSlug } from '@/lib/directory'
|
|
import { COPY, copy } from '@/lib/directoryCopy'
|
|
import { classifyFailure, isSessionError } from '@/lib/directoryErrors'
|
|
import {
|
|
BIO_MAX,
|
|
authorSlugProblem,
|
|
bioProblem,
|
|
nameProblem,
|
|
optionalSecureUrlProblem,
|
|
secureUrlProblem,
|
|
} from '@/lib/directoryRules'
|
|
import type { LinkableUser, DirectoryNotice } from '@/lib/directoryTypes'
|
|
import type { SocialLinkRow } from '@/lib/socialLinks'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
author?: Author
|
|
ownProfile?: boolean
|
|
ownEmail?: string
|
|
knownSlugs?: string[]
|
|
}>(),
|
|
{
|
|
author: undefined,
|
|
ownProfile: false,
|
|
ownEmail: '',
|
|
knownSlugs: () => [],
|
|
},
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
saved: [author: Author]
|
|
updated: [author: Author]
|
|
gone: [id: number]
|
|
}>()
|
|
|
|
const { createAuthor, updateAuthor, listLinkableUsers } = useAuthors()
|
|
const toasts = useDirectoryToasts()
|
|
const overlay = useLocalizedOverlay()
|
|
|
|
const editing = computed(() => props.author !== undefined)
|
|
const initialLinks: SocialLinkRow[] = (props.author?.social_links ?? []).map(
|
|
(link: SocialLink, index) => ({
|
|
key: index + 1,
|
|
type: link.type,
|
|
url: link.url,
|
|
}),
|
|
)
|
|
|
|
const values = reactive({
|
|
name: props.author?.name ?? '',
|
|
bio: props.author?.bio ?? '',
|
|
website: props.author?.website ?? '',
|
|
avatarId: props.author?.avatar_id ?? null,
|
|
userId: null as number | null,
|
|
})
|
|
const links = ref<SocialLinkRow[]>(initialLinks)
|
|
const avatarItem = ref<MediaItem | null>(null)
|
|
const baseUserId = ref<number | null>(props.author?.user_id ?? null)
|
|
const linkedEmail = ref(props.author?.user_email ?? props.ownEmail)
|
|
const slug = useSlugField(AUTHOR_SLUG_MAX, props.author?.slug)
|
|
slug.setSource(values.name)
|
|
|
|
const errors = reactive({
|
|
name: '',
|
|
slug: '',
|
|
suggestion: '',
|
|
bio: '',
|
|
website: '',
|
|
user: '',
|
|
})
|
|
const linkErrors = ref<Record<number, string>>({})
|
|
const saving = ref(false)
|
|
const notice = ref<DirectoryNotice | null>(null)
|
|
const panel = ref<{ focusFirstInvalid: () => void } | null>(null)
|
|
const rejectedSlugs = new Set<string>()
|
|
|
|
const userStatus = ref<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
|
const users = ref<LinkableUser[]>([])
|
|
const userMenuOpen = ref(false)
|
|
|
|
function otherFields() {
|
|
return JSON.stringify([
|
|
values.name,
|
|
slug.value.value,
|
|
values.bio,
|
|
values.website,
|
|
values.avatarId,
|
|
links.value.map((link) => [link.type, link.url]),
|
|
])
|
|
}
|
|
const initial = otherFields()
|
|
const dirty = computed(() => otherFields() !== initial || values.userId !== null)
|
|
|
|
const bioCount = computed(() => characterCount(values.bio))
|
|
const title = computed(() => (props.author ? t('Edit {0}', [props.author.name]) : t('Add author')))
|
|
const submitLabel = computed(() => (editing.value ? t('Save changes') : t('Add author')))
|
|
const showLinkedBox = computed(() => baseUserId.value !== null && !props.ownProfile)
|
|
const picker = computed(() => ({
|
|
title: t('Choose an image'),
|
|
description: t('The avatar for {0}.', [values.name || t('this author')]),
|
|
hint: t('Shown as a square. Choose an image with the face or logo near the middle.'),
|
|
confirmLabel: t('Use image'),
|
|
square: true,
|
|
}))
|
|
|
|
function retryUsers() {
|
|
userStatus.value = 'idle'
|
|
loadUsers()
|
|
}
|
|
|
|
async function loadUsers() {
|
|
if (userStatus.value === 'loading' || userStatus.value === 'ready') {
|
|
return
|
|
}
|
|
userStatus.value = 'loading'
|
|
try {
|
|
users.value = await listLinkableUsers()
|
|
userStatus.value = 'ready'
|
|
} catch {
|
|
userStatus.value = 'error'
|
|
}
|
|
}
|
|
|
|
function onName(value: string) {
|
|
values.name = value
|
|
slug.setSource(value)
|
|
errors.name = ''
|
|
clearSlugError()
|
|
}
|
|
|
|
function clearSlugError() {
|
|
errors.slug = ''
|
|
errors.suggestion = ''
|
|
}
|
|
|
|
function onSlug(value: string) {
|
|
slug.setValue(value)
|
|
clearSlugError()
|
|
}
|
|
|
|
function resetSlug() {
|
|
slug.reset()
|
|
clearSlugError()
|
|
}
|
|
|
|
function checkName() {
|
|
errors.name = nameProblem(values.name)
|
|
return errors.name === ''
|
|
}
|
|
|
|
function checkBio() {
|
|
errors.bio = bioProblem(values.bio)
|
|
return errors.bio === ''
|
|
}
|
|
|
|
function checkWebsite() {
|
|
errors.website = optionalSecureUrlProblem(values.website)
|
|
return errors.website === ''
|
|
}
|
|
|
|
function checkLink(key: number) {
|
|
const row = links.value.find((candidate) => candidate.key === key)
|
|
if (!row) {
|
|
return true
|
|
}
|
|
const problem = secureUrlProblem(row.url)
|
|
linkErrors.value = { ...linkErrors.value, [key]: problem }
|
|
return problem === ''
|
|
}
|
|
|
|
function checkLinks() {
|
|
return links.value.map((row) => checkLink(row.key)).every(Boolean)
|
|
}
|
|
|
|
function checkSlug() {
|
|
errors.slug = authorSlugProblem(slug.value.value)
|
|
return errors.slug === ''
|
|
}
|
|
|
|
function validate() {
|
|
const results = [checkName(), checkSlug(), checkBio(), checkWebsite(), checkLinks()]
|
|
return results.every(Boolean)
|
|
}
|
|
|
|
function onLinksChange(rows: SocialLinkRow[]) {
|
|
links.value = rows
|
|
}
|
|
|
|
function socialPayload(): SocialLink[] {
|
|
return links.value.map((row) => ({ type: row.type, url: row.url.trim() }))
|
|
}
|
|
|
|
function changedFields(author: Author) {
|
|
const patch: AuthorPatch = {}
|
|
if (values.name.trim() !== author.name) {
|
|
patch.name = values.name.trim()
|
|
}
|
|
if (slug.value.value !== author.slug) {
|
|
patch.slug = slug.value.value
|
|
}
|
|
if (values.bio !== author.bio) {
|
|
patch.bio = values.bio
|
|
}
|
|
if (values.avatarId !== author.avatar_id) {
|
|
patch.avatar_id = values.avatarId
|
|
}
|
|
if ((values.website.trim() || null) !== author.website) {
|
|
patch.website = values.website.trim() || null
|
|
}
|
|
if (JSON.stringify(socialPayload()) !== JSON.stringify(author.social_links)) {
|
|
patch.social_links = socialPayload()
|
|
}
|
|
if (values.userId !== null) {
|
|
patch.user_id = values.userId
|
|
}
|
|
return patch
|
|
}
|
|
|
|
async function submit() {
|
|
if (saving.value) {
|
|
return
|
|
}
|
|
notice.value = null
|
|
if (!validate()) {
|
|
panel.value?.focusFirstInvalid()
|
|
return
|
|
}
|
|
const author = props.author
|
|
if (author && !dirty.value) {
|
|
emit('close')
|
|
return
|
|
}
|
|
saving.value = true
|
|
try {
|
|
const saved = author
|
|
? await updateAuthor(author.id, changedFields(author))
|
|
: await createAuthor({
|
|
name: values.name.trim(),
|
|
slug: slug.value.value,
|
|
bio: values.bio,
|
|
avatar_id: values.avatarId,
|
|
website: values.website.trim() || null,
|
|
social_links: socialPayload(),
|
|
user_id: values.userId,
|
|
})
|
|
emit('saved', saved)
|
|
} catch (error) {
|
|
handleFailure(error)
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
function showSlugTaken() {
|
|
const taken = slug.value.value
|
|
if (slug.mode.value === 'auto') {
|
|
slug.unlock()
|
|
}
|
|
rejectedSlugs.add(taken)
|
|
errors.slug = t('The slug "{0}" is already used by another author.', [taken])
|
|
errors.suggestion = suggestFreeSlug(
|
|
taken,
|
|
(candidate) => rejectedSlugs.has(candidate) || props.knownSlugs.includes(candidate),
|
|
AUTHOR_SLUG_MAX,
|
|
)
|
|
}
|
|
|
|
function showInvalidUrl() {
|
|
const first = links.value[0]
|
|
if (values.website.trim() || !first) {
|
|
errors.website = copy('urlHttps')
|
|
return
|
|
}
|
|
linkErrors.value = { ...linkErrors.value, [first.key]: copy('urlHttps') }
|
|
}
|
|
|
|
function handleFailure(error: unknown) {
|
|
const kind = classifyFailure(error, 'author')
|
|
if (kind === 'session') {
|
|
return
|
|
}
|
|
if (kind === 'slugTaken') {
|
|
showSlugTaken()
|
|
return
|
|
}
|
|
if (kind === 'userLinked') {
|
|
errors.user = copy('userLinked')
|
|
return
|
|
}
|
|
if (kind === 'bioTooLong') {
|
|
errors.bio = copy('shortenBio')
|
|
return
|
|
}
|
|
if (kind === 'invalidUrl') {
|
|
showInvalidUrl()
|
|
return
|
|
}
|
|
if (toasts.report(kind, 'author')) {
|
|
if (kind === 'gone' && props.author) {
|
|
emit('gone', props.author.id)
|
|
}
|
|
return
|
|
}
|
|
notice.value = { description: copy('authorSaveFailed') }
|
|
}
|
|
|
|
async function confirmUnlink() {
|
|
const author = props.author
|
|
if (!author) {
|
|
return
|
|
}
|
|
const email = linkedEmail.value
|
|
await overlay
|
|
.create(ConfirmDialog, {
|
|
props: {
|
|
get title() {
|
|
return t('Unlink {0}?', [author.name])
|
|
},
|
|
get description() {
|
|
return t(
|
|
'{0} will stay on their articles. New articles by {1} will no longer start with this author.',
|
|
[author.name, email],
|
|
)
|
|
},
|
|
get confirmLabel() {
|
|
return t('Unlink user')
|
|
},
|
|
get cancelLabel() {
|
|
return t('Cancel')
|
|
},
|
|
get pendingLabel() {
|
|
return t('Unlinking…')
|
|
},
|
|
tone: 'neutral',
|
|
action: async () => {
|
|
try {
|
|
const updated = await updateAuthor(author.id, {
|
|
user_id: null,
|
|
})
|
|
baseUserId.value = null
|
|
linkedEmail.value = ''
|
|
emit('updated', updated)
|
|
toasts.userUnlinked(author.name)
|
|
} catch (error) {
|
|
if (isSessionError(error)) {
|
|
return false
|
|
}
|
|
throw new Error(COPY.nothingChanged)
|
|
}
|
|
},
|
|
},
|
|
})
|
|
.open()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<DirectoryPanel
|
|
ref="panel"
|
|
:title="title"
|
|
:submit-label="submitLabel"
|
|
:saving="saving"
|
|
:dirty="dirty"
|
|
:notice="notice"
|
|
@close="emit('close')"
|
|
@submit="submit"
|
|
>
|
|
<MediaField
|
|
v-model="values.avatarId"
|
|
v-model:item="avatarItem"
|
|
:label="t('Avatar')"
|
|
square
|
|
:avatar-name="values.name || 'Author'"
|
|
:picker="picker"
|
|
/>
|
|
|
|
<DirectoryField :label="t('Name')" :error="errors.name">
|
|
<UInput
|
|
:model-value="values.name"
|
|
:placeholder="t('e.g. Camille Roche')"
|
|
:color="errors.name ? 'error' : undefined"
|
|
:data-autofocus="editing ? undefined : ''"
|
|
autocomplete="off"
|
|
class="w-full"
|
|
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
|
|
@update:model-value="onName(String($event))"
|
|
@blur="checkName"
|
|
/>
|
|
</DirectoryField>
|
|
|
|
<SlugField
|
|
:label="t('Slug')"
|
|
:mode="slug.mode.value"
|
|
:value="slug.value.value"
|
|
:error="errors.slug"
|
|
:suggestion="errors.suggestion"
|
|
:help="
|
|
t(
|
|
'Generated from the name. Used in public author URLs and API paths. Unique across all authors.',
|
|
)
|
|
"
|
|
:manual-help="t('Lowercase letters, numbers and hyphens. Unique across all authors.')"
|
|
@update:value="onSlug"
|
|
@unlock="slug.unlock"
|
|
@reset="resetSlug"
|
|
@suggest="onSlug"
|
|
/>
|
|
|
|
<DirectoryField :label="t('Bio')" optional :error="errors.bio">
|
|
<template #hint>
|
|
<CharacterCounter :value="bioCount" :max="BIO_MAX" variant="field" />
|
|
</template>
|
|
<UTextarea
|
|
v-model="values.bio"
|
|
:placeholder="t('A sentence or two about the author.')"
|
|
:color="errors.bio ? 'error' : undefined"
|
|
class="w-full"
|
|
:ui="{ base: 'min-h-30 text-lg md:min-h-24 md:text-sm' }"
|
|
@update:model-value="errors.bio = ''"
|
|
@blur="checkBio"
|
|
/>
|
|
</DirectoryField>
|
|
|
|
<DirectoryField :label="t('Website')" optional :error="errors.website">
|
|
<UInput
|
|
v-model="values.website"
|
|
placeholder="https://example.com"
|
|
:color="errors.website ? 'error' : undefined"
|
|
inputmode="url"
|
|
autocomplete="off"
|
|
class="w-full"
|
|
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
|
|
@update:model-value="errors.website = ''"
|
|
@blur="checkWebsite"
|
|
/>
|
|
</DirectoryField>
|
|
|
|
<SocialLinksRepeater
|
|
:model-value="links"
|
|
:errors="linkErrors"
|
|
:disabled="saving"
|
|
@update:model-value="onLinksChange"
|
|
@blur="checkLink"
|
|
/>
|
|
|
|
<DirectoryField
|
|
v-if="ownProfile"
|
|
:label="t('Linked user')"
|
|
:help="t('This is your author profile. Only an owner or admin can change the link.')"
|
|
>
|
|
<div
|
|
class="flex min-h-11 items-center gap-2.5 border border-default bg-elevated px-2.5 md:min-h-10"
|
|
>
|
|
<UIcon name="i-lucide-link" class="size-3.5 shrink-0 text-info" />
|
|
<span class="min-w-0 flex-1 truncate text-highlighted">{{ linkedEmail }}</span>
|
|
<UIcon name="i-lucide-lock" class="size-3.5 shrink-0 text-muted" />
|
|
</div>
|
|
</DirectoryField>
|
|
|
|
<DirectoryField
|
|
v-else-if="showLinkedBox"
|
|
:label="t('Linked user')"
|
|
optional
|
|
:help="t('New articles by this user start with this author.')"
|
|
>
|
|
<div
|
|
class="flex min-h-11 items-center gap-2.5 border border-accented py-0 pr-1.5 pl-2.5 md:min-h-10"
|
|
>
|
|
<UIcon name="i-lucide-link" class="size-3.5 shrink-0 text-info" />
|
|
<span class="min-w-0 flex-1 truncate text-highlighted">{{ linkedEmail }}</span>
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
class="h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
|
|
@click="confirmUnlink"
|
|
>
|
|
{{ t('Unlink') }}
|
|
</UButton>
|
|
</div>
|
|
</DirectoryField>
|
|
|
|
<DirectoryField
|
|
v-else
|
|
:label="t('Linked user')"
|
|
optional
|
|
:error="errors.user"
|
|
:help="
|
|
userMenuOpen
|
|
? ''
|
|
: t(
|
|
'Link a CMS user if this author has an account. Guest writers need no link.',
|
|
)
|
|
"
|
|
>
|
|
<UserSelect
|
|
v-model="values.userId"
|
|
v-model:open="userMenuOpen"
|
|
:users="users"
|
|
:status="userStatus === 'idle' ? 'ready' : userStatus"
|
|
:idle-placeholder="t('No linked user')"
|
|
:invalid="errors.user !== ''"
|
|
clearable
|
|
@open="loadUsers"
|
|
@retry="retryUsers"
|
|
@update:model-value="errors.user = ''"
|
|
/>
|
|
</DirectoryField>
|
|
|
|
<template #preview>
|
|
<div class="flex items-start gap-3 bg-elevated p-3">
|
|
<AuthorAvatar
|
|
:name="values.name || 'Author'"
|
|
:avatar-id="values.avatarId"
|
|
:size="40"
|
|
/>
|
|
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
<span class="text-xs text-muted">{{ t('Byline preview') }}</span>
|
|
<span class="truncate font-semibold text-highlighted">{{
|
|
values.name || t('Author name')
|
|
}}</span>
|
|
<span class="line-clamp-2 text-xs/4.5 text-toned">{{
|
|
values.bio || t('The bio appears here.')
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</DirectoryPanel>
|
|
</template>
|