753 lines
20 KiB
Vue
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>
|