193 lines
5.0 KiB
TypeScript
193 lines
5.0 KiB
TypeScript
import { nextTick, ref, type Ref } from 'vue'
|
|
import { t } from '@/i18n'
|
|
import { useLocalizedToast } from '@/i18n/presentation'
|
|
import { useArticleActions } from '@/composables/useArticleActions'
|
|
import type { Article, ArticleCounts } from '@/composables/useArticles'
|
|
import type { ArticleListQuery } from '@/lib/articleListQuery'
|
|
import { pageCountFor } from '@/lib/pagination'
|
|
|
|
interface ArticleRowActionsState {
|
|
items: Ref<Article[]>
|
|
total: Ref<number>
|
|
counts: Ref<ArticleCounts>
|
|
shown: Ref<ArticleListQuery>
|
|
reload: (query: ArticleListQuery, mode: 'push' | 'replace') => void
|
|
}
|
|
|
|
export function useArticleRowActions({
|
|
items,
|
|
total,
|
|
counts,
|
|
shown,
|
|
reload,
|
|
}: ArticleRowActionsState) {
|
|
const actions = useArticleActions()
|
|
const toast = useLocalizedToast()
|
|
const busyIds = ref<Set<number>>(new Set())
|
|
|
|
function setBusy(id: number, busy: boolean) {
|
|
const next = new Set(busyIds.value)
|
|
if (busy) {
|
|
next.add(id)
|
|
} else {
|
|
next.delete(id)
|
|
}
|
|
busyIds.value = next
|
|
}
|
|
|
|
function shiftCounts(article: Article, delta: number) {
|
|
const key = article.is_online ? 'online' : 'draft'
|
|
counts.value = {
|
|
...counts.value,
|
|
all: Math.max(0, counts.value.all + delta),
|
|
[key]: Math.max(0, counts.value[key] + delta),
|
|
}
|
|
}
|
|
|
|
function detachRow(id: number) {
|
|
if (!items.value.some((article) => article.id === id)) {
|
|
return
|
|
}
|
|
items.value = items.value.filter((article) => article.id !== id)
|
|
total.value = Math.max(0, total.value - 1)
|
|
if (items.value.length === 0 && total.value > 0) {
|
|
const page = Math.min(shown.value.page, pageCountFor(total.value, shown.value.size))
|
|
reload({ ...shown.value, page }, 'replace')
|
|
}
|
|
}
|
|
|
|
function removeArticle(article: Article) {
|
|
shiftCounts(article, -1)
|
|
detachRow(article.id)
|
|
}
|
|
|
|
function replaceArticle(updated: Article, previous: Article) {
|
|
items.value = items.value.map((existing) =>
|
|
existing.id === updated.id ? updated : existing,
|
|
)
|
|
if (updated.is_online === previous.is_online) {
|
|
return
|
|
}
|
|
counts.value = {
|
|
...counts.value,
|
|
online: counts.value.online + (updated.is_online ? 1 : -1),
|
|
draft: counts.value.draft + (updated.is_online ? -1 : 1),
|
|
}
|
|
const filter = shown.value.status
|
|
if (filter && (filter === 'online') !== updated.is_online) {
|
|
detachRow(updated.id)
|
|
}
|
|
}
|
|
|
|
function notFoundToast(article: Article) {
|
|
return {
|
|
id: `article-not-found-${article.id}`,
|
|
color: 'info' as const,
|
|
icon: 'i-lucide-info',
|
|
get title() {
|
|
return t('That article no longer exists')
|
|
},
|
|
get description() {
|
|
return t('It was deleted somewhere else, so we removed it from the list.')
|
|
},
|
|
}
|
|
}
|
|
|
|
async function setOnline(article: Article, makeOnline: boolean) {
|
|
setBusy(article.id, true)
|
|
try {
|
|
await actions.publish(article, makeOnline, {
|
|
onUpdated: (updated) => replaceArticle(updated, article),
|
|
onGone: () => goneFromList(article),
|
|
retry: () => setOnline(article, makeOnline),
|
|
})
|
|
} finally {
|
|
setBusy(article.id, false)
|
|
}
|
|
}
|
|
|
|
function findItem(id: number) {
|
|
return items.value.find((existing) => existing.id === id)
|
|
}
|
|
|
|
function handlePublish(id: number) {
|
|
const article = findItem(id)
|
|
if (article) {
|
|
setOnline(article, true)
|
|
}
|
|
}
|
|
|
|
function handleUnpublish(id: number) {
|
|
const article = findItem(id)
|
|
if (article) {
|
|
setOnline(article, false)
|
|
}
|
|
}
|
|
|
|
async function handleCopyUrl(id: number) {
|
|
const article = findItem(id)
|
|
if (!article) {
|
|
return
|
|
}
|
|
const url = `${window.location.origin}/v1/article/${id}`
|
|
try {
|
|
await navigator.clipboard.writeText(url)
|
|
toast.add(() => ({
|
|
id: `article-link-copied-${id}`,
|
|
color: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
get title() {
|
|
return t('Link copied')
|
|
},
|
|
get description() {
|
|
return t('The API URL for \u0022{0}\u0022 is on your clipboard.', [
|
|
article.title,
|
|
])
|
|
},
|
|
}))
|
|
} catch {
|
|
toast.add(() => ({
|
|
id: `article-link-copy-failed-${id}`,
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-x',
|
|
get title() {
|
|
return t("Couldn't copy the link")
|
|
},
|
|
}))
|
|
}
|
|
}
|
|
|
|
function goneFromList(article: Article) {
|
|
removeArticle(article)
|
|
toast.add(() => notFoundToast(article))
|
|
}
|
|
|
|
function performDelete(article: Article) {
|
|
return actions.deleteArticle(article, {
|
|
onDeleted: () => removeArticle(article),
|
|
onGone: () => goneFromList(article),
|
|
})
|
|
}
|
|
|
|
async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) {
|
|
await nextTick()
|
|
const index = idsAtOpen.indexOf(deletedId)
|
|
const nextId = idsAtOpen[index + 1] ?? idsAtOpen.slice(0, index).reverse()[0]
|
|
if (nextId != null) {
|
|
document.querySelector<HTMLElement>(`[data-row-title="${nextId}"]`)?.focus()
|
|
return
|
|
}
|
|
document.querySelector<HTMLElement>('[data-testid="articles-heading"]')?.focus()
|
|
}
|
|
|
|
async function handleDelete(article: Article) {
|
|
const idsAtOpen = items.value.map((existing) => existing.id)
|
|
const confirmed = await actions.confirmDelete(article, () => performDelete(article))
|
|
if (confirmed) {
|
|
focusRowOrHeading(idsAtOpen, article.id)
|
|
}
|
|
}
|
|
|
|
return { busyIds, handlePublish, handleUnpublish, handleCopyUrl, handleDelete }
|
|
}
|