feat(SPA): screens for Sites and Authors
This commit is contained in:
1 parent
b34a173d83
commit
3d81d2c717
82 files changed
+10676
-37
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user