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

+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
const props = defineProps<{
articles: Article[]
loading: boolean
busyIds: Set<number>
now?: Date
}>()
const emit = defineEmits<{
publish: [id: number]
unpublish: [id: number]
delete: [article: Article]
'copy-url': [id: number]
}>()
const router = useRouter()
function editPath(article: Article) {
return `/article/${article.id}/edit`
}
function isBusy(article: Article) {
return props.busyIds.has(article.id)
}
function pendingFor(article: Article) {
if (!isBusy(article)) {
return undefined
}
return article.is_online ? ('unpublish' as const) : ('publish' as const)
}
function actionsFor(article: Article) {
return [
[
{
label: 'Edit',
icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)),
},
{
label: article.is_online ? 'Unpublish' : 'Publish',
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article),
onSelect: () => {
if (article.is_online) {
emit('unpublish', article.id)
} else {
emit('publish', article.id)
}
},
},
{
label: 'Copy API URL',
icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id),
},
],
[
{
label: 'Delete…',
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete', article),
},
],
]
}
function onRowClick(article: Article, event: MouseEvent) {
if ((event.target as HTMLElement).closest('[data-row-menu]')) {
return
}
router.push(editPath(article))
}
</script>
<template>
<ul
class="-mx-4 border-y border-default bg-default md:hidden"
:aria-busy="loading ? 'true' : undefined"
>
<template v-if="loading">
<li
v-for="n in 5"
:key="n"
class="flex min-h-17 flex-col justify-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
>
<USkeleton class="h-3.5 w-3/4 rounded-none bg-accented" />
<USkeleton class="h-3 w-2/5 rounded-none bg-accented" />
</li>
</template>
<template v-else>
<li v-if="articles.length === 0">
<slot name="empty" />
</li>
<li
v-for="article in articles"
:key="article.id"
class="relative flex min-h-17 items-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
:class="{ 'opacity-55': isBusy(article) }"
@click="onRowClick(article, $event)"
>
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<RouterLink
:to="editPath(article)"
:data-row-title="article.id"
class="truncate text-[0.9375rem]/[21px] font-medium text-highlighted"
@click.stop
>
{{ article.title }}
</RouterLink>
<div class="flex items-center gap-2.5 text-xs text-muted">
<ArticleStatusBadge
:online="article.is_online"
size="sm"
:pending="pendingFor(article)"
/>
<span :title="article.updated_at"
>Updated {{ formatRelativeDate(article.updated_at, now) }}</span
>
</div>
</div>
<UDropdownMenu
:items="actionsFor(article)"
:content="{ align: 'end' }"
data-row-menu
>
<UButton
icon="i-lucide-ellipsis-vertical"
color="neutral"
variant="ghost"
:aria-label="`Actions for ${article.title}`"
class="size-11 justify-center text-toned"
@click.stop
/>
</UDropdownMenu>
</li>
</template>
</ul>
</template>
+9 -3
View File
@@ -3,6 +3,7 @@ withDefaults(
defineProps<{
online: boolean
size?: 'sm' | 'md' | 'lg'
pending?: 'publish' | 'unpublish'
}>(),
{
size: 'md',
@@ -12,12 +13,17 @@ withDefaults(
<template>
<UBadge
v-if="online"
v-if="pending"
color="neutral"
variant="solid"
icon="i-lucide-globe"
variant="outline"
icon="i-lucide-loader-circle"
:size="size"
:class="size === 'lg' ? undefined : 'px-2'"
:ui="{ leadingIcon: 'animate-spin' }"
>
{{ pending === 'publish' ? 'Publishing…' : 'Unpublishing…' }}
</UBadge>
<UBadge v-else-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
Online
</UBadge>
<UBadge
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
const props = defineProps<{
articles: Article[]
loading: boolean
busyIds: Set<number>
now?: Date
}>()
const emit = defineEmits<{
publish: [id: number]
unpublish: [id: number]
delete: [article: Article]
'copy-url': [id: number]
}>()
const router = useRouter()
const columnWidths =
'[&:nth-child(2)]:w-27.5 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-35 [&:nth-child(5)]:w-11'
const columns = [
{ accessorKey: 'title', header: 'Title' },
{ accessorKey: 'is_online', header: 'Status', size: 110 },
{ accessorKey: 'created_at', header: 'Created', size: 130 },
{ accessorKey: 'updated_at', header: 'Updated', size: 140 },
{ id: 'actions', header: '', size: 44 },
]
function editPath(article: Article) {
return `/article/${article.id}/edit`
}
function isBusy(article: Article) {
return props.busyIds.has(article.id)
}
function pendingFor(article: Article) {
if (!isBusy(article)) {
return undefined
}
return article.is_online ? ('unpublish' as const) : ('publish' as const)
}
function actionsFor(article: Article) {
return [
[
{
label: 'Edit',
icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)),
},
{
label: article.is_online ? 'Unpublish' : 'Publish',
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article),
onSelect: () => {
if (article.is_online) {
emit('unpublish', article.id)
} else {
emit('publish', article.id)
}
},
},
{
label: 'Copy API URL',
icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id),
},
],
[
{
label: 'Delete…',
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete', article),
},
],
]
}
function onSelect(row: { original: Article }) {
router.push(editPath(row.original))
}
</script>
<template>
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
<span role="status" class="sr-only">Loading articles</span>
<div
v-for="n in 5"
:key="n"
class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0"
>
<USkeleton class="h-3 max-w-[55%] flex-1 rounded-none bg-accented" />
<USkeleton class="h-5 w-15 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
</div>
</div>
<div v-else class="hidden border border-default bg-default md:block">
<UTable
:data="articles"
:columns="columns"
:ui="{
base: 'table-fixed',
tr: 'cursor-pointer',
th: `px-3 ${columnWidths}`,
td: `h-11 px-3 py-0 ${columnWidths}`,
}"
@select="onSelect"
>
<template #caption>
<caption class="sr-only">
Articles, sorted by last updated, newest first
</caption>
</template>
<template #title-cell="{ row }">
<RouterLink
:to="editPath(row.original)"
:data-row-title="row.original.id"
class="font-medium text-highlighted"
@click.stop
>
{{ row.original.title }}
</RouterLink>
</template>
<template #is_online-cell="{ row }">
<ArticleStatusBadge
:online="row.original.is_online"
size="sm"
:pending="pendingFor(row.original)"
:aria-busy="isBusy(row.original) ? 'true' : undefined"
/>
</template>
<template #updated_at-header>
<span class="inline-flex items-center gap-1 text-highlighted">
Updated
<UIcon name="i-lucide-arrow-down" class="size-3" />
</span>
</template>
<template #created_at-cell="{ row }">
<span class="text-muted">{{
formatAbsoluteDateFromIso(row.original.created_at)
}}</span>
</template>
<template #updated_at-cell="{ row }">
<span class="text-muted" :title="row.original.updated_at">{{
formatRelativeDate(row.original.updated_at, now)
}}</span>
</template>
<template #actions-cell="{ row }">
<UDropdownMenu :items="actionsFor(row.original)" :content="{ align: 'end' }">
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
:aria-label="`Actions for ${row.original.title}`"
class="size-8 justify-center text-toned"
@click.stop
/>
</UDropdownMenu>
</template>
<template #empty>
<slot name="empty" />
</template>
</UTable>
</div>
</template>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
const props = defineProps<{
variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online'
query?: string
}>()
const emit = defineEmits<{ action: [] }>()
const truncatedQuery = computed(() => {
const value = props.query ?? ''
return value.length > QUERY_MAX_LENGTH ? value.slice(0, QUERY_MAX_LENGTH) : value
})
const variants = computed(() => ({
'no-articles': {
icon: 'i-lucide-file-text',
title: 'No articles yet',
description: 'Write your first one. It stays a draft until you publish it.',
action: 'New article',
primary: true,
},
'no-results': {
icon: 'i-lucide-search',
title: `No articles match "${truncatedQuery.value}"`,
description: 'Check the spelling, or search for a shorter part of the title.',
action: 'Clear search',
primary: false,
},
'no-drafts': {
icon: 'i-lucide-pencil-line',
title: 'No drafts',
description: "Everything you've written is online.",
action: 'New article',
primary: false,
},
'no-online': {
icon: 'i-lucide-globe',
title: 'Nothing is online yet',
description: 'Publish a draft and it will appear on your website.',
action: 'Show drafts',
primary: false,
},
}))
const current = computed(() => variants.value[props.variant])
</script>
<template>
<UEmpty
variant="naked"
:icon="current.icon"
:title="current.title"
:description="current.description"
: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-[0.9375rem] font-semibold',
description: 'max-w-70 text-muted text-pretty',
}"
>
<template #actions>
<UButton
:color="current.primary ? 'primary' : 'neutral'"
:variant="current.primary ? 'solid' : 'outline'"
class="h-8 px-3"
@click="emit('action')"
>
{{ current.action }}
</UButton>
</template>
</UEmpty>
</template>