feat(SPA): screens for Sites and Authors

This commit is contained in:
Florian Sylvain committed 2026-10-14 23:00:50 +02:00
1 parent b34a173d83
commit 3d81d2c717
82 files changed
+10676 -37

No files matched your search

+536
View File
@@ -0,0 +1,536 @@
<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'),
}))
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'" :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>