Files
RenewCMS/web/src/views/AuthorsView.vue
T

753 lines
20 KiB
Vue

<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation'
import AppPaginator from '@/components/AppPaginator.vue'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import AuthorFormPanel from '@/components/AuthorFormPanel.vue'
import AuthorViewPanel from '@/components/AuthorViewPanel.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
import DirectoryBadge from '@/components/DirectoryBadge.vue'
import DirectoryList from '@/components/DirectoryList.vue'
import LinkUserDialog from '@/components/LinkUserDialog.vue'
import ReassignDialog, { type ReassignOption } from '@/components/ReassignDialog.vue'
import { useAuthors, type Author } from '@/composables/useAuthors'
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useDirectoryView } from '@/composables/useDirectoryView'
import { sameRouteQuery } from '@/lib/articleListQuery'
import { pluralArticles } from '@/lib/directory'
import { copy } from '@/lib/directoryCopy'
import { classifyFailure } from '@/lib/directoryErrors'
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
import {
parseDirectoryQuery,
toDirectoryRouteQuery,
type DirectoryQuery,
} from '@/lib/directoryQuery'
import { pageCountFor } from '@/lib/pagination'
import { useSessionStore } from '@/stores/session'
const SEARCH_DEBOUNCE_MS = 120
const DESKTOP_QUERY = '(min-width: 48rem)'
const LIST_COLUMNS = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px'
const SKELETON_ROWS = 6
const PAGINATOR_THRESHOLD = 25
const NOBODY = 'none'
const LOOKUP_SIZE = 100
type PanelTarget =
| { kind: 'add' }
| { kind: 'edit'; author: Author; own: boolean }
| { kind: 'view'; author: Author }
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const overlay = useLocalizedOverlay()
const toasts = useDirectoryToasts()
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const { listAuthors, listAllAuthors, deleteAuthor } = useAuthors()
const collection = useDirectoryCollection(listAuthors)
const removal = useDirectoryDelete<Author>()
const { data: result, showSkeleton } = collection
const { target: deleteTarget, pending: deletePending, notice: deleteNotice } = removal
const loadState = computed(() =>
collection.state.value === 'forbidden' ? 'error' : collection.state.value,
)
const pendingPage = ref<number | null>(null)
const searchText = ref(parseDirectoryQuery(route.query).q)
const panel = ref<PanelTarget | null>(null)
const linkTarget = ref<Author | null>(null)
const deleteOptions = ref<ReassignOption[]>([])
const destination = ref<string | number | undefined>(undefined)
let searchTimer: ReturnType<typeof setTimeout> | null = null
const sessionReady = computed(() => session.status === 'ready')
const canManage = computed(() => session.canManageContent)
const query = computed(() => parseDirectoryQuery(route.query))
const queryKey = computed(() => JSON.stringify(query.value))
const items = computed(() => result.value?.items ?? [])
const total = computed(() => result.value?.total ?? 0)
const pageCount = computed(() => pageCountFor(total.value, query.value.size))
const firstRun = computed(
() => loadState.value === 'ready' && total.value === 0 && query.value.q === '',
)
const noResults = computed(
() => loadState.value === 'ready' && total.value === 0 && query.value.q !== '',
)
const showToolbar = computed(() => loadState.value !== 'error' && !firstRun.value)
const showList = computed(
() =>
(loadState.value === 'ready' && total.value > 0) ||
(loadState.value === 'loading' && showSkeleton.value),
)
const showPaginator = computed(
() => loadState.value === 'ready' && (total.value > PAGINATOR_THRESHOLD || pageCount.value > 1),
)
const overlayOpen = computed(
() => panel.value !== null || linkTarget.value !== null || deleteTarget.value !== null,
)
const showPinnedButton = computed(
() =>
!isDesktop.value &&
canManage.value &&
!overlayOpen.value &&
loadState.value === 'ready' &&
!firstRun.value,
)
const description = computed(() =>
canManage.value
? t("The names readers see on your articles. An author doesn't need an account.")
: t('The names readers see on your articles. Only an owner or admin can change authors.'),
)
const headings = computed(() => [t('Author'), t('Slug'), t('Account'), t('Articles'), ''])
const ownSlug = computed(() => session.session?.author?.slug ?? '')
const knownSlugs = computed(() => items.value.map((author) => author.slug))
function clearSearchTimer() {
if (searchTimer !== null) {
clearTimeout(searchTimer)
searchTimer = null
}
}
async function load(requested: DirectoryQuery) {
if (result.value !== null) {
pendingPage.value = requested.page
}
const outcome = await collection.load([requested], {
accept: (response) => {
const lastPage = pageCountFor(response.total, requested.size)
if (response.total > 0 && requested.page > lastPage) {
replaceQuery({ ...requested, page: lastPage })
return false
}
return true
},
})
if (outcome !== 'stale') {
pendingPage.value = null
}
if (outcome === 'ready') {
followLink()
}
}
async function replaceQuery(next: DirectoryQuery, mode: 'push' | 'replace' = 'replace') {
const target = toDirectoryRouteQuery(next, route.query)
if (sameRouteQuery(route.query, target)) {
return
}
await router[mode]({ query: target })
}
function normalizeUrl() {
const target = toDirectoryRouteQuery(query.value, route.query)
if (!sameRouteQuery(route.query, target)) {
router.replace({ query: target })
}
}
watch(
[sessionReady, queryKey],
([ready]) => {
if (!ready) {
return
}
normalizeUrl()
load(query.value)
},
{ immediate: true },
)
watch(
() => query.value.q,
(value) => {
if (value !== searchText.value.trim()) {
searchText.value = value
}
},
)
watch(searchText, (value) => {
clearSearchTimer()
searchTimer = setTimeout(() => {
searchTimer = null
const text = value.trim()
if (text !== query.value.q) {
replaceQuery({ q: text, page: 1, size: query.value.size })
}
}, SEARCH_DEBOUNCE_MS)
})
watch(
() => route.query.edit,
() => {
if (loadState.value === 'ready') {
followLink()
}
},
)
onBeforeUnmount(clearSearchTimer)
function clearSearch() {
clearSearchTimer()
searchText.value = ''
replaceQuery({ q: '', page: 1, size: query.value.size })
}
function changePage(page: number) {
replaceQuery({ ...query.value, page }, 'push')
}
function changeSize(size: number) {
replaceQuery({ ...query.value, size, page: 1 }, 'push')
}
async function findBySlug(slug: string) {
const loaded = items.value.find((author) => author.slug === slug)
if (loaded) {
return loaded
}
try {
const found = await listAuthors({ q: slug, page: 1, size: LOOKUP_SIZE })
return found.items.find((author) => author.slug === slug) ?? null
} catch {
return null
}
}
function openFor(author: Author): PanelTarget {
if (canManage.value) {
return { kind: 'edit', author, own: false }
}
return author.slug === ownSlug.value
? { kind: 'edit', author, own: true }
: { kind: 'view', author }
}
function followLink() {
return followEditLink(
findBySlug,
(author) => {
panel.value = openFor(author)
},
toasts.authorGone,
)
}
function openAdd() {
rememberOpener()
panel.value = { kind: 'add' }
}
function openEdit(author: Author, trigger: HTMLElement | null) {
rememberOpener(trigger)
panel.value = { kind: 'edit', author, own: false }
}
function openRow(author: Author) {
rememberOpener()
panel.value = { kind: 'view', author }
}
function closePanel() {
panel.value = null
leavePanel()
}
function replaceItem(author: Author) {
if (!result.value) {
return
}
result.value = {
...result.value,
items: result.value.items.map((item) =>
item.id === author.id
? { ...author, user_email: author.user_email ?? item.user_email }
: item,
),
}
}
function removeItem(id: number) {
if (!result.value) {
return
}
const remaining = result.value.items.filter((item) => item.id !== id)
result.value = { ...result.value, items: remaining, total: Math.max(0, result.value.total - 1) }
if (remaining.length === 0 && result.value.total > 0) {
load(query.value)
}
}
function onSaved(author: Author) {
if (items.value.some((item) => item.id === author.id)) {
replaceItem(author)
} else {
load(query.value)
}
closePanel()
}
function onGone(id: number) {
removeItem(id)
closePanel()
}
function openLink(author: Author, trigger: HTMLElement | null) {
rememberOpener(trigger)
linkTarget.value = author
}
function closeLink() {
linkTarget.value = null
restoreFocus()
}
function onLinked(author: Author) {
replaceItem(author)
closeLink()
}
function onLinkGone(id: number) {
removeItem(id)
closeLink()
}
function nobodyOption(): ReassignOption {
return {
value: NOBODY,
label: t('Nobody'),
description: t('Remove the byline'),
display: t('Nobody'),
none: true,
}
}
async function loadDeleteOptions(author: Author) {
let others: Author[] = []
let failed = false
try {
others = (await listAllAuthors()).filter((candidate) => candidate.id !== author.id)
} catch {
failed = true
}
const options: ReassignOption[] = [
...others.map((candidate) => ({
value: candidate.id,
label: candidate.name,
description: candidate.slug,
display: candidate.name,
avatarName: candidate.name,
})),
nobodyOption(),
]
return { options, failed }
}
async function openDelete(author: Author, trigger: HTMLElement | null) {
rememberOpener(trigger)
const loaded = author.article_count > 0 ? await loadDeleteOptions(author) : null
deleteOptions.value = loaded?.options ?? []
destination.value = deleteOptions.value[0]?.value
removal.open(author)
if (loaded?.failed) {
removal.notice.value = {
title: copy('loadAuthorsFailed'),
description: copy('checkConnection'),
}
}
}
function closeDelete() {
removal.close()
restoreFocus()
}
const chosenAuthorName = computed(
() => deleteOptions.value.find((option) => option.value === destination.value)?.label ?? '',
)
const deleteBody = computed(() => {
const target = deleteTarget.value
if (!target) {
return { description: '', summary: '', confirm: '', pending: '' }
}
if (target.article_count === 0) {
return {
description: t(
'"{0}" will be deleted. No articles credit this author. This can\'t be undone.',
[target.name],
),
summary: '',
confirm: t('Delete author'),
pending: t('Deleting…'),
}
}
const many = target.article_count !== 1
return {
description: many
? t('"{0}" will be deleted and is credited on {1} articles. This can\'t be undone.', [
target.name,
target.article_count,
])
: t('"{0}" will be deleted and is credited on 1 article. This can\'t be undone.', [
target.name,
]),
summary:
destination.value === NOBODY
? t(
'The articles keep any other authors. Articles with only this author will have no byline.',
)
: t('Where an article has several authors, {0} is removed and the others stay.', [
target.name,
]),
confirm: many
? t('Delete and reassign {0} articles', [target.article_count])
: t('Delete and reassign 1 article'),
pending: t('Reassigning…'),
}
})
async function runDelete(target: Author) {
const credited = target.article_count
const reassignTo =
credited === 0
? undefined
: destination.value === NOBODY
? NOBODY
: Number(destination.value)
const receiver = reassignTo === NOBODY || credited === 0 ? null : chosenAuthorName.value
await deleteAuthor(target.id, reassignTo)
removeItem(target.id)
toasts.authorDeleted(credited, receiver)
closeDelete()
}
async function handleDeleteFailure(error: unknown, target: Author) {
const kind = classifyFailure(error, 'author')
if (kind === 'session') {
return
}
if (kind === 'hasArticles') {
closeDelete()
await showBlocked(target)
return
}
if (toasts.report(kind, 'author')) {
if (kind === 'gone') {
removeItem(target.id)
}
closeDelete()
return
}
removal.notice.value = {
title: t("Couldn't delete the author"),
description: copy('nothingChanged'),
}
}
function confirmDelete() {
return removal.confirm(runDelete, handleDeleteFailure)
}
async function creditedCount(target: Author) {
try {
const found = await listAuthors({ q: target.slug, page: 1, size: LOOKUP_SIZE })
return (
found.items.find((item) => item.id === target.id)?.article_count || target.article_count
)
} catch {
return target.article_count
}
}
async function showBlocked(target: Author) {
const count = Math.max(1, await creditedCount(target))
const reassign = await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t("Can't delete this author")
},
get description() {
return count === 1
? t(
'"{0}" is credited on 1 article. Choose another author to credit instead, or remove {0} from those articles first.',
[target.name],
)
: t(
'"{0}" is credited on {1} articles. Choose another author to credit instead, or remove {0} from those articles first.',
[target.name, count],
)
},
get confirmLabel() {
return t('Reassign articles')
},
get cancelLabel() {
return t('Close')
},
get pendingLabel() {
return t('Reassign articles')
},
tone: 'neutral',
action: async () => undefined,
},
})
.open()
if (reassign) {
await openDelete({ ...target, article_count: count }, null)
} else {
restoreFocus()
}
}
function menuItems(author: Author): DirectoryMenuItem[] {
const items: DirectoryMenuItem[] = [
{
key: 'edit',
label: t('Edit'),
icon: 'i-lucide-pencil',
onSelect: (trigger) => openEdit(author, trigger),
},
]
if (!author.is_linked) {
items.push({
key: 'link',
label: t('Link user'),
icon: 'i-lucide-link',
onSelect: (trigger) => openLink(author, trigger),
})
}
items.push({
key: 'delete',
label: t('Delete…'),
icon: 'i-lucide-trash-2',
destructive: true,
separated: true,
onSelect: (trigger) => openDelete(author, trigger),
})
return items
}
const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q]))
</script>
<template>
<DashboardShell compact-padding :mobile-title="t('Authors')">
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ t('Authors') }}</h1>
<p class="text-muted">{{ description }}</p>
</div>
<DirectoryAddButton v-if="isDesktop && canManage" @click="openAdd">
{{ t('Add author') }}
</DirectoryAddButton>
</div>
<UInput
v-if="showToolbar"
v-model="searchText"
type="search"
icon="i-lucide-search"
:placeholder="t('Search by name or slug')"
:aria-label="t('Search by name or slug')"
class="w-full sm:w-70"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
/>
<UAlert
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load authors')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="load(query)"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<UEmpty
v-else-if="firstRun"
variant="naked"
icon="i-lucide-feather"
:title="t('No authors yet')"
:description="
t('Authors are the names readers see on articles. They don\'t need an account.')
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-85 text-pretty text-muted',
}"
>
<template v-if="canManage" #actions>
<UButton icon="i-lucide-plus" class="h-11 px-4 md:h-8" @click="openAdd">
{{ t('Add author') }}
</UButton>
</template>
</UEmpty>
<UEmpty
v-else-if="noResults"
variant="naked"
icon="i-lucide-search"
:title="noResultsTitle"
:description="t('Check the spelling, or search for a shorter part of the name.')"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-85 text-pretty text-muted',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
@click="clearSearch"
>
{{ t('Clear search') }}
</UButton>
</template>
</UEmpty>
<DirectoryList
v-else-if="showList"
:items="items"
:columns="LIST_COLUMNS"
:headings="headings"
:loading="loadState === 'loading'"
:skeleton-rows="SKELETON_ROWS"
:read-only="!canManage"
:label="t('Authors')"
:row-title="(author: Author) => author.name"
:menu-items="menuItems"
@open="openRow"
>
<template #cells="{ item }">
<div class="flex min-w-0 items-center gap-2.5">
<AuthorAvatar :name="item.name" :size="32" />
<span class="truncate font-medium text-highlighted" :title="item.name">{{
item.name
}}</span>
</div>
<span class="truncate font-mono text-xs/4.5 text-muted">{{ item.slug }}</span>
<span class="flex">
<DirectoryBadge :kind="item.is_linked ? 'linked' : 'guest'" />
</span>
<span class="text-muted">{{ pluralArticles(item.article_count) }}</span>
</template>
<template #card="{ item }">
<AuthorAvatar :name="item.name" :size="40" />
<div class="flex min-w-0 flex-1 flex-col gap-0.75">
<span
class="truncate text-md/5.25 font-medium text-highlighted"
:title="item.name"
>{{ item.name }}</span
>
<span class="truncate font-mono text-xs/4 text-muted">{{ item.slug }}</span>
<div class="flex items-center gap-2 text-xs text-muted">
<DirectoryBadge :kind="item.is_linked ? 'linked' : 'guest'" />
<span>{{ pluralArticles(item.article_count) }}</span>
</div>
</div>
</template>
</DirectoryList>
<AppPaginator
v-if="showPaginator"
:page="query.page"
:page-count="pageCount"
:total="total"
:size="query.size"
:loading-page="pendingPage"
@update:page="changePage"
@update:size="changeSize"
/>
</div>
<DirectoryAddButton v-if="showPinnedButton" pinned @click="openAdd">
{{ t('Add author') }}
</DirectoryAddButton>
<AuthorFormPanel
v-if="panel && (panel.kind === 'add' || panel.kind === 'edit')"
:key="panel.kind === 'edit' ? panel.author.id : 'add'"
:author="panel.kind === 'edit' ? panel.author : undefined"
:own-profile="panel.kind === 'edit' && panel.own"
:own-email="session.session?.email ?? ''"
:known-slugs="knownSlugs"
@close="closePanel"
@saved="onSaved"
@updated="replaceItem"
@gone="onGone"
/>
<AuthorViewPanel
v-if="panel && panel.kind === 'view'"
:author="panel.author"
@close="closePanel"
/>
<LinkUserDialog
v-if="linkTarget"
:author="linkTarget"
@close="closeLink"
@linked="onLinked"
@gone="onLinkGone"
/>
<ReassignDialog
v-if="deleteTarget"
v-model:selected="destination"
closable
:title="t('Delete this author?')"
:description="deleteBody.description"
:confirm-label="deleteBody.confirm"
:pending-label="deleteBody.pending"
:select-label="t('Credit these articles to')"
:options="deleteOptions"
:summary="deleteBody.summary"
:pending="deletePending"
:notice="deleteNotice"
@confirm="confirmDelete"
@close="closeDelete"
>
<p v-if="deleteTarget.is_linked && deleteTarget.user_email" class="text-xs text-muted">
{{
t("Linked to {0}. The user account isn't affected.", [deleteTarget.user_email])
}}
</p>
</ReassignDialog>
</DashboardShell>
</template>