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,752 @@
|
||||
<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>
|
||||
Reference in new issue
Block a user