615 lines
17 KiB
Vue
615 lines
17 KiB
Vue
<script setup lang="ts">
|
|
import { t, formatNumber } from '@/i18n'
|
|
|
|
import { computed, ref, watch } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { useMediaQuery } from '@vueuse/core'
|
|
import { useLocalizedOverlay } from '@/i18n/presentation'
|
|
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 DirectoryLoadError from '@/components/DirectoryLoadError.vue'
|
|
import DirectoryNoResults from '@/components/DirectoryNoResults.vue'
|
|
import DirectoryPageNotice from '@/components/DirectoryPageNotice.vue'
|
|
import DirectoryPagination from '@/components/DirectoryPagination.vue'
|
|
import LinkUserDialog from '@/components/LinkUserDialog.vue'
|
|
import ReassignDialog, { type ReassignOption } from '@/components/ReassignDialog.vue'
|
|
import { useAuthors, type Author } from '@/composables/useAuthors'
|
|
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
|
|
import { useDirectoryListState } from '@/composables/useDirectoryListState'
|
|
import { useDirectoryPages } from '@/composables/useDirectoryPages'
|
|
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
|
import { useDirectoryView } from '@/composables/useDirectoryView'
|
|
import { pluralArticles } from '@/lib/directory'
|
|
import { copy } from '@/lib/directoryCopy'
|
|
import { classifyFailure } from '@/lib/directoryErrors'
|
|
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
|
import { useSessionStore } from '@/stores/session'
|
|
|
|
const DESKTOP_QUERY = '(min-width: 48rem)'
|
|
const LIST_COLUMNS = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px'
|
|
const INITIAL_SKELETON_ROWS = 6
|
|
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 session = useSessionStore()
|
|
const overlay = useLocalizedOverlay()
|
|
const toasts = useDirectoryToasts()
|
|
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
|
|
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
|
const { listAuthors, listAuthorPage, listAllAuthors, deleteAuthor } = useAuthors()
|
|
const removal = useDirectoryDelete<Author>()
|
|
|
|
const sessionReady = computed(() => session.status === 'ready')
|
|
const list = useDirectoryPages<Author>({
|
|
loadPage: listAuthorPage,
|
|
ready: () => sessionReady.value,
|
|
onLoaded: () => followLink(),
|
|
})
|
|
const { items, total, shown, notice, searchInput, pageCount } = list
|
|
const { target: deleteTarget, pending: deletePending, notice: deleteNotice } = removal
|
|
const loadState = computed(() => (list.state.value === 'forbidden' ? 'error' : list.state.value))
|
|
const panel = ref<PanelTarget | null>(null)
|
|
const linkTarget = ref<Author | null>(null)
|
|
const deleteOptions = ref<ReassignOption[]>([])
|
|
const destination = ref<string | number | undefined>(undefined)
|
|
|
|
const canManage = computed(() => session.canManageContent)
|
|
const firstRun = computed(
|
|
() => loadState.value === 'ready' && total.value === 0 && shown.value.q === '',
|
|
)
|
|
const { noResults, showList, skeletonRows, showPaginator, singlePageLabel } = useDirectoryListState(
|
|
{
|
|
list,
|
|
loadState,
|
|
isDesktop,
|
|
initialRows: INITIAL_SKELETON_ROWS,
|
|
singleLabel: (count) =>
|
|
count === 1 ? t('1 author') : t('{0} authors', [formatNumber(count)]),
|
|
},
|
|
)
|
|
const showToolbar = computed(() => loadState.value !== 'error' && !firstRun.value)
|
|
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))
|
|
|
|
watch(
|
|
() => route.query.edit,
|
|
() => {
|
|
if (loadState.value === 'ready') {
|
|
followLink()
|
|
}
|
|
},
|
|
)
|
|
|
|
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) {
|
|
items.value = items.value.map((item) =>
|
|
item.id === author.id
|
|
? {
|
|
...author,
|
|
user_email: author.user_email ?? item.user_email,
|
|
}
|
|
: item,
|
|
)
|
|
}
|
|
|
|
function removeItem(id: number) {
|
|
list.removeById(id, (item) => item.id)
|
|
}
|
|
|
|
function onSaved(author: Author) {
|
|
if (items.value.some((item) => item.id === author.id)) {
|
|
replaceItem(author)
|
|
} else {
|
|
list.reload()
|
|
}
|
|
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,
|
|
avatarId: candidate.avatar_id,
|
|
})),
|
|
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}"', [shown.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="searchInput"
|
|
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 ps-8.25 sm:h-8 sm:text-sm', leadingIcon: 'size-3.5' }"
|
|
/>
|
|
|
|
<DirectoryPageNotice
|
|
v-if="notice?.kind === 'range'"
|
|
:notice="notice"
|
|
:shown-page="shown.page"
|
|
/>
|
|
|
|
<DirectoryLoadError
|
|
v-if="loadState === 'error'"
|
|
:title="t('Couldn\'t load authors')"
|
|
@retry="list.retry"
|
|
/>
|
|
|
|
<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>
|
|
|
|
<DirectoryNoResults
|
|
v-else-if="noResults"
|
|
:title="noResultsTitle"
|
|
@clear="list.clearSearch"
|
|
/>
|
|
|
|
<DirectoryList
|
|
v-else-if="showList"
|
|
:items="items"
|
|
:columns="LIST_COLUMNS"
|
|
:headings="headings"
|
|
:loading="list.skeletonActive.value"
|
|
:skeleton-rows="skeletonRows"
|
|
: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" :avatar-id="item.avatar_id" :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" :avatar-id="item.avatar_id" :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>
|
|
|
|
<DirectoryPagination
|
|
:notice="notice"
|
|
:show-paginator="showPaginator"
|
|
:shown-page="shown.page"
|
|
:page-count="pageCount"
|
|
:total="total"
|
|
:size="shown.size"
|
|
:loading-page="list.pendingPage.value"
|
|
:single-page-label="singlePageLabel"
|
|
@retry="list.retry"
|
|
@update:page="list.changePage"
|
|
@update:size="list.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>
|