Files
RenewCMS/web/src/components/ArticleCardList.vue
T

150 lines
4.0 KiB
Vue

<script setup lang="ts">
import { t } from '@/i18n'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import SeoStatusChip from '@/components/SeoStatusChip.vue'
import AuthorStack from '@/components/AuthorStack.vue'
import { authorsSummary } from '@/lib/articleMeta'
import { formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
import { useArticleMenu } from '@/composables/useArticleMenu'
import { seoChipOf } from '@/lib/articleSeoStatus'
const props = withDefaults(
defineProps<{
articles: Article[]
loading: boolean
skeletonRows?: number
busyIds: Set<number>
now?: Date
}>(),
{ skeletonRows: 5 },
)
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)
}
const { actionsFor, publishHint } = useArticleMenu({
isBusy,
edit: (article) => router.push(editPath(article)),
publish: (id) => emit('publish', id),
unpublish: (id) => emit('unpublish', id),
copyUrl: (id) => emit('copy-url', id),
remove: (article) => 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 skeletonRows"
: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
v-if="publishHint(article)"
class="sr-only"
data-testid="publish-hint"
>{{ publishHint(article) }}</span
>
<SeoStatusChip
v-if="seoChipOf(article)"
:chip="seoChipOf(article)!"
size="sm"
/>
<span :title="article.updated_at"
>{{ t('Updated') }}
{{ formatRelativeDate(article.updated_at, now) }}</span
>
</div>
<div class="flex min-w-0 items-center gap-2 text-xs text-muted">
<AuthorStack
v-if="article.authors?.length"
:authors="article.authors"
:size="22"
/>
<span class="truncate">{{ authorsSummary(article.authors ?? []) }}</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="t('Actions for {0}', [article.title])"
class="size-11 justify-center text-toned"
@click.stop
/>
</UDropdownMenu>
</li>
</template>
</ul>
</template>