feat: articles list screen

This commit is contained in:
fsylvain committed 2026-09-27 17:12:23 +02:00
1 parent ca3fe20519
commit f6d0d01092
14 files changed
+2353 -25

No files matched your search

+406 -1
View File
@@ -1,9 +1,414 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
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 { ApiError, useApi } from '@/composables/useApi'
import { useArticles, type Article } from '@/composables/useArticles'
const SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120
const route = useRoute()
const router = useRouter()
const { request } = useApi()
const { listArticles } = useArticles()
const overlay = useOverlay()
const toast = useToast()
const articles = ref<Article[]>([])
const loading = ref(true)
const loadFailed = ref(false)
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
const searchInput = ref(String(route.query.q ?? ''))
const debouncedSearch = ref(searchInput.value)
function normalize(value: string) {
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
watch(searchInput, (value) => {
if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer)
}
searchDebounceTimer = setTimeout(() => {
debouncedSearch.value = value
router.replace({
query: { ...route.query, q: value || undefined },
})
}, SEARCH_DEBOUNCE_MS)
})
const activeTab = computed<'' | 'online' | 'draft'>({
get: () =>
route.query.status === 'online' || route.query.status === 'draft' ? route.query.status : '',
set: (value) => {
router.replace({ query: { ...route.query, status: value || undefined } })
},
})
const counts = computed(() => {
const online = articles.value.filter((article) => article.is_online).length
return {
all: articles.value.length,
online,
drafts: articles.value.length - online,
}
})
const tabItems = computed(() => [
{ label: `All ${counts.value.all}`, value: '' },
{ label: `Online ${counts.value.online}`, value: 'online' },
{ label: `Drafts ${counts.value.drafts}`, value: 'draft' },
])
const statusFilteredArticles = computed(() => {
if (activeTab.value === 'online') {
return articles.value.filter((article) => article.is_online)
}
if (activeTab.value === 'draft') {
return articles.value.filter((article) => !article.is_online)
}
return articles.value
})
const displayedArticles = computed(() => {
const search = normalize(debouncedSearch.value.trim())
if (!search) {
return statusFilteredArticles.value
}
return statusFilteredArticles.value.filter((article) =>
normalize(article.title).includes(search),
)
})
const emptyVariant = computed(() => {
if (loading.value || loadFailed.value) {
return null
}
if (articles.value.length === 0) {
return 'no-articles' as const
}
if (debouncedSearch.value.trim() && displayedArticles.value.length === 0) {
return 'no-results' as const
}
if (statusFilteredArticles.value.length === 0) {
return activeTab.value === 'draft' ? ('no-drafts' as const) : ('no-online' as const)
}
return null
})
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function loadArticles() {
loading.value = true
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
loadFailed.value = false
try {
articles.value = await listArticles()
} catch {
articles.value = []
loadFailed.value = true
} finally {
loading.value = false
clearSkeletonTimer()
showSkeleton.value = false
}
}
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 removeArticle(id: number) {
articles.value = articles.value.filter((article) => article.id !== id)
}
function notFoundToast(article: Article) {
return {
id: `article-not-found-${article.id}`,
color: 'info' as const,
icon: 'i-lucide-info',
title: 'That article no longer exists',
description: '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'}`,
{ method: 'PATCH' },
)
articles.value = articles.value.map((existing) =>
existing.id === updated.id ? updated : existing,
)
toast.add({
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: makeOnline ? 'Article published' : 'Article unpublished',
description: makeOnline
? `"${article.title}" is now online.`
: `"${article.title}" is now offline.`,
})
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id)
toast.add(notFoundToast(article))
return
}
toast.add({
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${article.id}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: makeOnline ? "Couldn't publish" : "Couldn't unpublish",
description: "The server didn't respond. Nothing changed.",
duration: 0,
actions: [{ label: 'Retry', onClick: () => setOnline(article, makeOnline) }],
})
} finally {
setBusy(article.id, false)
}
}
function handlePublish(id: number) {
const article = articles.value.find((existing) => existing.id === id)
if (article) {
setOnline(article, true)
}
}
function handleUnpublish(id: number) {
const article = articles.value.find((existing) => existing.id === id)
if (article) {
setOnline(article, false)
}
}
async function handleCopyUrl(id: number) {
const article = articles.value.find((existing) => existing.id === 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',
title: 'Link copied',
description: `The API URL for "${article.title}" is on your clipboard.`,
})
} catch {
toast.add({
id: `article-link-copy-failed-${id}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't copy the link",
})
}
}
function deleteDescription(article: Article) {
const count = article.images.length
const imagesPhrase =
count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images`
return `"${article.title}"${imagesPhrase} will be deleted. This can't be undone.`
}
async function performDelete(article: Article) {
try {
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
removeArticle(article.id)
toast.add({
id: `article-deleted-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: `"${article.title}" and its images are gone.`,
})
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id)
toast.add(notFoundToast(article))
return
}
throw new Error("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 = displayedArticles.value.map((existing) => existing.id)
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
title: 'Delete this article?',
description: deleteDescription(article),
confirmLabel: 'Delete article',
cancelLabel: 'Cancel',
pendingLabel: '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() {
searchInput.value = ''
debouncedSearch.value = ''
router.replace({ query: { ...route.query, q: undefined } })
nextTick(() => {
document.querySelector<HTMLInputElement>('input[placeholder="Search by title"]')?.focus()
})
}
function onEmptyAction() {
if (emptyVariant.value === 'no-results') {
clearSearch()
return
}
if (emptyVariant.value === 'no-online') {
activeTab.value = 'draft'
return
}
openNewArticleModal()
}
onMounted(loadArticles)
</script>
<template>
<DashboardShell>
<p>Articles placeholder.</p>
<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"
>
Articles
</h1>
<p class="text-muted">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"
>
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="Search by title"
class="w-full sm:w-65"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
/>
</div>
<template v-if="!loading || showSkeleton">
<EmptyState
v-if="emptyVariant"
:variant="emptyVariant"
:query="debouncedSearch"
@action="onEmptyAction"
/>
<template v-else>
<ArticleTable
:articles="displayedArticles"
:loading="loading && showSkeleton"
:busy-ids="busyIds"
@publish="handlePublish"
@unpublish="handleUnpublish"
@delete="handleDelete"
@copy-url="handleCopyUrl"
/>
<ArticleCardList
:articles="displayedArticles"
:loading="loading && showSkeleton"
:busy-ids="busyIds"
@publish="handlePublish"
@unpublish="handleUnpublish"
@delete="handleDelete"
@copy-url="handleCopyUrl"
/>
</template>
</template>
</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"
>
New article
</UButton>
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
</DashboardShell>
</template>