677 lines
17 KiB
Vue
677 lines
17 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
|
import DashboardShell from '@/components/DashboardShell.vue'
|
|
import ArticleTable from '@/components/ArticleTable.vue'
|
|
import ArticleCardList from '@/components/ArticleCardList.vue'
|
|
import EmptyState from '@/components/EmptyState.vue'
|
|
import NewArticleModal from '@/components/NewArticleModal.vue'
|
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
|
import AppPaginator from '@/components/AppPaginator.vue'
|
|
import { ApiError, useApi } from '@/composables/useApi'
|
|
import {
|
|
useArticles,
|
|
type Article,
|
|
type ArticleCounts,
|
|
type ArticleStatus,
|
|
} from '@/composables/useArticles'
|
|
import { bodyImageCount } from '@/lib/bodyImageIds'
|
|
import {
|
|
effectiveSort,
|
|
nextSort,
|
|
parseArticleQuery,
|
|
type ArticleSortField,
|
|
sameArticleQuery,
|
|
sameRouteQuery,
|
|
toRouteQuery,
|
|
type ArticleListQuery,
|
|
} from '@/lib/articleListQuery'
|
|
import { pageCountFor } from '@/lib/pagination'
|
|
|
|
const SKELETON_DELAY_MS = 150
|
|
const SEARCH_DEBOUNCE_MS = 120
|
|
const DEFAULT_SKELETON_ROWS = 5
|
|
const ARTICLES_PATH = '/article'
|
|
|
|
type UrlMode = 'push' | 'replace'
|
|
type Notice = { kind: 'range' } | { kind: 'error'; query: ArticleListQuery; mode: UrlMode }
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const { request } = useApi()
|
|
const { listArticles } = useArticles()
|
|
const overlay = useLocalizedOverlay()
|
|
const toast = useLocalizedToast()
|
|
|
|
const items = ref<Article[]>([])
|
|
const total = ref(0)
|
|
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
|
|
const shown = ref<ArticleListQuery>(parseArticleQuery(route.query))
|
|
const desired = ref<ArticleListQuery>(shown.value)
|
|
const hasLoaded = ref(false)
|
|
const loading = ref(true)
|
|
const loadFailed = ref(false)
|
|
const pendingPage = ref<number | null>(null)
|
|
const notice = ref<Notice | null>(null)
|
|
const showSkeleton = ref(false)
|
|
const busyIds = ref<Set<number>>(new Set())
|
|
const newArticleModalOpen = ref(false)
|
|
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
let requestToken = 0
|
|
|
|
const searchInput = ref(shown.value.q)
|
|
|
|
function clearSearchTimer() {
|
|
if (searchDebounceTimer !== null) {
|
|
clearTimeout(searchDebounceTimer)
|
|
searchDebounceTimer = null
|
|
}
|
|
}
|
|
|
|
watch(searchInput, (value) => {
|
|
clearSearchTimer()
|
|
searchDebounceTimer = setTimeout(() => changeSearch(value), SEARCH_DEBOUNCE_MS)
|
|
})
|
|
|
|
const activeTab = computed<'' | ArticleStatus>({
|
|
get: () => desired.value.status,
|
|
set: (value) => changeStatus(value),
|
|
})
|
|
|
|
const tabItems = computed(() => [
|
|
{
|
|
get label() {
|
|
return t('All {0}', [counts.value.all])
|
|
},
|
|
value: '',
|
|
},
|
|
{
|
|
get label() {
|
|
return t('Online {0}', [counts.value.online])
|
|
},
|
|
value: 'online',
|
|
},
|
|
{
|
|
get label() {
|
|
return t('Drafts {0}', [counts.value.draft])
|
|
},
|
|
value: 'draft',
|
|
},
|
|
])
|
|
|
|
const settled = computed(() => hasLoaded.value && !loadFailed.value)
|
|
const skeletonActive = computed(() => loading.value && showSkeleton.value)
|
|
const skeletonRows = computed(() => items.value.length || DEFAULT_SKELETON_ROWS)
|
|
|
|
const emptyVariant = computed(() => {
|
|
if (!settled.value || skeletonActive.value || total.value > 0) {
|
|
return null
|
|
}
|
|
if (shown.value.q) {
|
|
return 'no-results' as const
|
|
}
|
|
if (shown.value.status === 'draft') {
|
|
return 'no-drafts' as const
|
|
}
|
|
if (shown.value.status === 'online') {
|
|
return 'no-online' as const
|
|
}
|
|
return 'no-articles' as const
|
|
})
|
|
|
|
const showPaginator = computed(() => settled.value && total.value > 0)
|
|
const pageCount = computed(() => pageCountFor(total.value, shown.value.size))
|
|
|
|
const noticeText = computed(() => {
|
|
const current = notice.value
|
|
if (!current) {
|
|
return ''
|
|
}
|
|
if (current.kind === 'range') {
|
|
return t("That page doesn't exist. Showing the last page.")
|
|
}
|
|
return t("Couldn't load page {0}. Showing page {1}.", [current.query.page, shown.value.page])
|
|
})
|
|
|
|
function clearSkeletonTimer() {
|
|
if (skeletonTimer !== null) {
|
|
clearTimeout(skeletonTimer)
|
|
skeletonTimer = null
|
|
}
|
|
}
|
|
|
|
async function syncUrl(query: ArticleListQuery, mode: UrlMode) {
|
|
if (route.path !== ARTICLES_PATH) {
|
|
return
|
|
}
|
|
const target = toRouteQuery(query)
|
|
if (sameRouteQuery(route.query, target)) {
|
|
return
|
|
}
|
|
await router[mode]({ query: target })
|
|
}
|
|
|
|
async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notice | null = null) {
|
|
const token = ++requestToken
|
|
desired.value = query
|
|
loading.value = true
|
|
showSkeleton.value = false
|
|
pendingPage.value = hasLoaded.value ? query.page : null
|
|
clearSkeletonTimer()
|
|
skeletonTimer = setTimeout(() => {
|
|
showSkeleton.value = true
|
|
}, SKELETON_DELAY_MS)
|
|
|
|
try {
|
|
const response = await listArticles(query)
|
|
if (token !== requestToken) {
|
|
return
|
|
}
|
|
const lastPage = pageCountFor(response.total, query.size)
|
|
if (
|
|
successNotice === null &&
|
|
response.items.length === 0 &&
|
|
response.total > 0 &&
|
|
query.page > lastPage
|
|
) {
|
|
await load({ ...query, page: lastPage }, 'replace', { kind: 'range' })
|
|
return
|
|
}
|
|
const effective = { ...query, page: response.total === 0 ? 1 : query.page }
|
|
items.value = response.items
|
|
total.value = response.total
|
|
counts.value = response.counts
|
|
shown.value = effective
|
|
desired.value = effective
|
|
hasLoaded.value = true
|
|
loadFailed.value = false
|
|
notice.value = successNotice
|
|
await syncUrl(effective, mode)
|
|
} catch {
|
|
if (token !== requestToken) {
|
|
return
|
|
}
|
|
if (hasLoaded.value) {
|
|
notice.value = { kind: 'error', query, mode }
|
|
} else {
|
|
items.value = []
|
|
loadFailed.value = true
|
|
}
|
|
} finally {
|
|
if (token === requestToken) {
|
|
loading.value = false
|
|
pendingPage.value = null
|
|
clearSkeletonTimer()
|
|
showSkeleton.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
function changePage(page: number) {
|
|
load({ ...desired.value, page }, 'push')
|
|
}
|
|
|
|
function changeSize(size: number) {
|
|
load({ ...desired.value, size, page: 1 }, 'push')
|
|
}
|
|
|
|
function changeStatus(status: '' | ArticleStatus) {
|
|
if (status === desired.value.status) {
|
|
return
|
|
}
|
|
load({ ...desired.value, status, page: 1 }, 'push')
|
|
}
|
|
|
|
function changeSort(field: ArticleSortField) {
|
|
load({ ...desired.value, sort: nextSort(desired.value.sort, field), page: 1 }, 'push')
|
|
}
|
|
|
|
function changeSearch(value: string) {
|
|
const q = value.trim()
|
|
if (q === desired.value.q) {
|
|
return
|
|
}
|
|
load({ ...desired.value, q, page: 1 }, 'replace')
|
|
}
|
|
|
|
function retryNotice() {
|
|
if (notice.value?.kind === 'error') {
|
|
load(notice.value.query, notice.value.mode)
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => route.query,
|
|
() => {
|
|
if (route.path !== ARTICLES_PATH) {
|
|
return
|
|
}
|
|
const next = parseArticleQuery(route.query)
|
|
if (hasLoaded.value && sameArticleQuery(next, shown.value)) {
|
|
syncUrl(shown.value, 'replace')
|
|
return
|
|
}
|
|
if (sameArticleQuery(next, desired.value) && loading.value) {
|
|
return
|
|
}
|
|
searchInput.value = next.q
|
|
load(next, 'replace')
|
|
},
|
|
)
|
|
|
|
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))
|
|
load({ ...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 {
|
|
const updated = await request<Article>(
|
|
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
|
|
{ method: 'PATCH' },
|
|
)
|
|
replaceArticle(updated, article)
|
|
toast.add(() => ({
|
|
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
|
|
color: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
get title() {
|
|
return makeOnline ? t('Article published') : t('Article unpublished')
|
|
},
|
|
get description() {
|
|
return makeOnline
|
|
? t('\u0022{0}\u0022 is now online.', [article.title])
|
|
: t('\u0022{0}\u0022 is now offline.', [article.title])
|
|
},
|
|
}))
|
|
} catch (err) {
|
|
if (err instanceof ApiError && err.status === 404) {
|
|
removeArticle(article)
|
|
toast.add(() => notFoundToast(article))
|
|
return
|
|
}
|
|
toast.add(() => ({
|
|
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${article.id}`,
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-x',
|
|
get title() {
|
|
return makeOnline ? t("Couldn't publish") : t("Couldn't unpublish")
|
|
},
|
|
get description() {
|
|
return t("The server didn't respond. Nothing changed.")
|
|
},
|
|
duration: 0,
|
|
actions: [
|
|
{
|
|
get label() {
|
|
return t('Retry')
|
|
},
|
|
onClick: () => 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 deleteDescription(article: Article) {
|
|
const count = bodyImageCount(article.body)
|
|
const imagesPhrase = count === 0 ? '' : t(' Its images stay in your library.')
|
|
return t("\u0022{0}\u0022 will be deleted.{1} This can't be undone.", [
|
|
article.title,
|
|
imagesPhrase,
|
|
])
|
|
}
|
|
|
|
async function performDelete(article: Article) {
|
|
try {
|
|
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
|
|
removeArticle(article)
|
|
toast.add(() => ({
|
|
id: `article-deleted-${article.id}`,
|
|
color: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
get title() {
|
|
return t('Article deleted')
|
|
},
|
|
get description() {
|
|
return t('\u0022{0}\u0022 is gone.', [article.title])
|
|
},
|
|
}))
|
|
} catch (err) {
|
|
if (err instanceof ApiError && err.status === 404) {
|
|
removeArticle(article)
|
|
toast.add(() => notFoundToast(article))
|
|
return
|
|
}
|
|
throw new Error(
|
|
t("Couldn't delete the article, so it's still there. Try again in a moment."),
|
|
)
|
|
}
|
|
}
|
|
|
|
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 overlay
|
|
.create(ConfirmDialog, {
|
|
props: {
|
|
get title() {
|
|
return t('Delete this article?')
|
|
},
|
|
get description() {
|
|
return deleteDescription(article)
|
|
},
|
|
get confirmLabel() {
|
|
return t('Delete article')
|
|
},
|
|
get cancelLabel() {
|
|
return t('Cancel')
|
|
},
|
|
get pendingLabel() {
|
|
return t('Deleting…')
|
|
},
|
|
tone: 'danger',
|
|
action: () => performDelete(article),
|
|
},
|
|
})
|
|
.open()
|
|
if (confirmed) {
|
|
focusRowOrHeading(idsAtOpen, article.id)
|
|
}
|
|
}
|
|
|
|
function openNewArticleModal() {
|
|
newArticleModalOpen.value = true
|
|
}
|
|
|
|
function onArticleCreated(article: Article) {
|
|
router.push(`/article/${article.id}/edit`)
|
|
}
|
|
|
|
function clearSearch() {
|
|
clearSearchTimer()
|
|
searchInput.value = ''
|
|
changeSearch('')
|
|
nextTick(() => {
|
|
document.querySelector<HTMLInputElement>('input[type="search"]')?.focus()
|
|
})
|
|
}
|
|
|
|
function onEmptyAction() {
|
|
if (emptyVariant.value === 'no-results') {
|
|
clearSearch()
|
|
return
|
|
}
|
|
if (emptyVariant.value === 'no-online') {
|
|
activeTab.value = 'draft'
|
|
return
|
|
}
|
|
openNewArticleModal()
|
|
}
|
|
|
|
onMounted(() => load(shown.value, 'replace'))
|
|
|
|
onBeforeUnmount(() => {
|
|
requestToken++
|
|
clearSearchTimer()
|
|
clearSkeletonTimer()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<DashboardShell>
|
|
<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
|
|
data-testid="articles-heading"
|
|
tabindex="-1"
|
|
class="text-xl font-semibold text-highlighted"
|
|
>
|
|
{{ t('Articles') }}
|
|
</h1>
|
|
<p class="text-muted">
|
|
{{ t("Everything you've written, online and in draft.") }}
|
|
</p>
|
|
</div>
|
|
<UButton
|
|
icon="i-lucide-plus"
|
|
class="hidden h-8 px-3 sm:flex"
|
|
@click="openNewArticleModal"
|
|
>
|
|
{{ t('New article') }}
|
|
</UButton>
|
|
</div>
|
|
|
|
<div class="flex flex-col-reverse gap-4 sm:flex-row sm:items-end sm:justify-between">
|
|
<UTabs
|
|
v-model="activeTab"
|
|
:items="tabItems"
|
|
variant="link"
|
|
color="neutral"
|
|
:content="false"
|
|
:ui="{ list: 'gap-5', trigger: 'px-0' }"
|
|
/>
|
|
<UInput
|
|
v-model="searchInput"
|
|
type="search"
|
|
icon="i-lucide-search"
|
|
:placeholder="t('Search by title')"
|
|
class="w-full sm:w-65"
|
|
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
|
|
/>
|
|
</div>
|
|
|
|
<UAlert
|
|
v-if="notice"
|
|
:role="notice.kind === 'error' ? 'alert' : 'status'"
|
|
:color="notice.kind === 'error' ? 'error' : 'info'"
|
|
variant="soft"
|
|
orientation="horizontal"
|
|
:close="false"
|
|
:icon="notice.kind === 'error' ? 'i-lucide-circle-x' : 'i-lucide-info'"
|
|
:description="noticeText"
|
|
:ui="{
|
|
root: 'items-center gap-2.5 px-3.5 py-2.5',
|
|
icon: 'size-4',
|
|
description: 'text-toned',
|
|
}"
|
|
>
|
|
<template v-if="notice.kind === 'error'" #actions>
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-rotate-cw"
|
|
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
|
|
:ui="{ leadingIcon: 'size-3.5' }"
|
|
@click="retryNotice"
|
|
>
|
|
{{ t('Retry') }}
|
|
</UButton>
|
|
</template>
|
|
</UAlert>
|
|
|
|
<template v-if="hasLoaded || loadFailed || showSkeleton">
|
|
<EmptyState
|
|
v-if="emptyVariant"
|
|
:variant="emptyVariant"
|
|
:query="shown.q"
|
|
@action="onEmptyAction"
|
|
/>
|
|
<template v-else>
|
|
<ArticleTable
|
|
:articles="items"
|
|
:loading="skeletonActive"
|
|
:skeleton-rows="skeletonRows"
|
|
:sort="effectiveSort(shown.sort)"
|
|
:busy-ids="busyIds"
|
|
@sort="changeSort"
|
|
@publish="handlePublish"
|
|
@unpublish="handleUnpublish"
|
|
@delete="handleDelete"
|
|
@copy-url="handleCopyUrl"
|
|
/>
|
|
<ArticleCardList
|
|
:articles="items"
|
|
:loading="skeletonActive"
|
|
:skeleton-rows="skeletonRows"
|
|
:busy-ids="busyIds"
|
|
@publish="handlePublish"
|
|
@unpublish="handleUnpublish"
|
|
@delete="handleDelete"
|
|
@copy-url="handleCopyUrl"
|
|
/>
|
|
</template>
|
|
</template>
|
|
|
|
<AppPaginator
|
|
v-if="showPaginator"
|
|
:page="shown.page"
|
|
:page-count="pageCount"
|
|
:total="total"
|
|
:size="shown.size"
|
|
:loading-page="pendingPage"
|
|
@update:page="changePage"
|
|
@update:size="changeSize"
|
|
/>
|
|
</div>
|
|
|
|
<UButton
|
|
icon="i-lucide-plus"
|
|
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover) sm:hidden"
|
|
@click="openNewArticleModal"
|
|
>
|
|
{{ t('New article') }}
|
|
</UButton>
|
|
|
|
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
|
|
</DashboardShell>
|
|
</template>
|