feat: editorial article list
This commit is contained in:
1 parent
a0b3c58c52
commit
a671b81f80
103 files changed
+6493
-1805
No files matched your search
+192
-72
@@ -1,39 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ArticleTable from '@/components/ArticleTable.vue'
|
||||
import ArticleCardList from '@/components/ArticleCardList.vue'
|
||||
import StoryTable from '@/components/StoryTable.vue'
|
||||
import StoryCardList from '@/components/StoryCardList.vue'
|
||||
import ArticleFilterBar from '@/components/ArticleFilterBar.vue'
|
||||
import ArticleFilterChips from '@/components/ArticleFilterChips.vue'
|
||||
import ArticleFiltersSheet from '@/components/ArticleFiltersSheet.vue'
|
||||
import ArticleListEmpty from '@/components/ArticleListEmpty.vue'
|
||||
import ArticleListFailure from '@/components/ArticleListFailure.vue'
|
||||
import ArticleListSkeleton from '@/components/ArticleListSkeleton.vue'
|
||||
import ArticleListNotice from '@/components/ArticleListNotice.vue'
|
||||
import ArticleListToolbar from '@/components/ArticleListToolbar.vue'
|
||||
import ArticleListTabs from '@/components/ArticleListTabs.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import OwnScopeEmpty from '@/components/OwnScopeEmpty.vue'
|
||||
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
|
||||
import NewArticleModal from '@/components/NewArticleModal.vue'
|
||||
import AppPaginator from '@/components/AppPaginator.vue'
|
||||
import { useArticleFilterOptions } from '@/composables/useArticleFilterOptions'
|
||||
import { useArticleList } from '@/composables/useArticleList'
|
||||
import { useArticleRowActions } from '@/composables/useArticleRowActions'
|
||||
import { useArticleSites } from '@/composables/useArticleSites'
|
||||
import { useContentAccess } from '@/composables/useContentAccess'
|
||||
import { useIsDesktop } from '@/composables/useIsDesktop'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { effectiveSort } from '@/lib/articleListQuery'
|
||||
import { filterChips, type FilterChipKey } from '@/lib/articleFilterChips'
|
||||
import type { ArticleFilterFieldProps } from '@/lib/articleFilterFields'
|
||||
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
|
||||
|
||||
const DEFAULT_SKELETON_ROWS = 6
|
||||
import { activeTabKey, listMode, listTabs, unitLabel } from '@/lib/storyList'
|
||||
import type { StoryMenuHandlers } from '@/lib/storyMenu'
|
||||
|
||||
const router = useRouter()
|
||||
const desktop = useIsDesktop()
|
||||
const access = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
|
||||
const writerLine = computed(() =>
|
||||
access.readsOwnOnly.value
|
||||
? t('Online articles and the drafts you created.')
|
||||
: access.canCreate.value &&
|
||||
access.updateScope.value === 'all' &&
|
||||
access.publishScope.value === 'none'
|
||||
? t('You can write and edit all articles. Publishing is done by an editor.')
|
||||
: t("Everything you've written, online and in draft."),
|
||||
)
|
||||
|
||||
const writerLine = computed(() => {
|
||||
if (access.readsOwnOnly.value) {
|
||||
return t('Online articles and the drafts you created.')
|
||||
}
|
||||
return access.canCreate.value &&
|
||||
access.updateScope.value === 'all' &&
|
||||
access.publishScope.value === 'none'
|
||||
? t('You can write and edit all articles. Publishing is done by an editor.')
|
||||
: ''
|
||||
})
|
||||
|
||||
const list = useArticleList({
|
||||
sitesSettled: () => siteList.sitesSettled.value,
|
||||
@@ -50,7 +63,6 @@ const {
|
||||
notice,
|
||||
showSkeleton,
|
||||
searchInput,
|
||||
needsAttentionCount,
|
||||
settled,
|
||||
skeletonActive,
|
||||
showPaginator,
|
||||
@@ -60,16 +72,58 @@ const {
|
||||
const siteList = useArticleSites(() => shown.value.site)
|
||||
const { sites, severalSites, siteName } = siteList
|
||||
const newArticleModalOpen = ref(false)
|
||||
const filtersOpen = ref(false)
|
||||
|
||||
const skeletonRows = computed(() => items.value.length || DEFAULT_SKELETON_ROWS)
|
||||
const optionsSiteKey = computed(
|
||||
() =>
|
||||
shown.value.site ||
|
||||
sites.value.find((site) => site.is_default)?.key ||
|
||||
sites.value[0]?.key ||
|
||||
'',
|
||||
)
|
||||
const { languages, languagesKnown, authors } = useArticleFilterOptions(() => optionsSiteKey.value)
|
||||
const defaultLang = computed(
|
||||
() => languages.value.find((language) => language.is_default)?.lang ?? '',
|
||||
)
|
||||
|
||||
watch([languagesKnown, hasLoaded], ([known, loaded]) => {
|
||||
if (known && loaded) {
|
||||
list.dropUnknownLanguage(languages.value.map((language) => language.lang))
|
||||
}
|
||||
})
|
||||
|
||||
const mode = computed(() => listMode(shown.value))
|
||||
const tabs = computed(() => listTabs(desired.value, counts.value))
|
||||
const activeTab = computed(() => activeTabKey(desired.value))
|
||||
const unit = computed(() => (settled.value ? unitLabel(shown.value, total.value) : ''))
|
||||
|
||||
const fields = computed<ArticleFilterFieldProps>(() => ({
|
||||
site: desired.value.site,
|
||||
sites: sites.value,
|
||||
severalSites: severalSites.value,
|
||||
lang: desired.value.lang,
|
||||
languages: languages.value,
|
||||
status: desired.value.status,
|
||||
author: desired.value.author,
|
||||
authors: authors.value,
|
||||
seo: desired.value.seo,
|
||||
suggestions: list.needsAttentionCount.value,
|
||||
}))
|
||||
const chips = computed(() => filterChips(desired.value, sites.value, authors.value))
|
||||
|
||||
const emptyVariant = computed(() => {
|
||||
if (!settled.value || skeletonActive.value || total.value > 0) {
|
||||
return null
|
||||
}
|
||||
if (shown.value.tab === 'missing') {
|
||||
return 'nothing-missing' as const
|
||||
}
|
||||
if (shown.value.q) {
|
||||
return 'no-results' as const
|
||||
}
|
||||
if (shown.value.author || shown.value.lang) {
|
||||
return 'no-matches' as const
|
||||
}
|
||||
if (shown.value.status === 'draft') {
|
||||
return 'no-drafts' as const
|
||||
}
|
||||
@@ -88,12 +142,27 @@ const emptyVariant = computed(() => {
|
||||
const { busyIds, handlePublish, handleUnpublish, handleCopyUrl, handleDelete } =
|
||||
useArticleRowActions({
|
||||
items,
|
||||
total,
|
||||
counts,
|
||||
shown,
|
||||
reload: list.load,
|
||||
})
|
||||
|
||||
function openVersion(id: number) {
|
||||
router.push(`/article/${id}/edit`)
|
||||
}
|
||||
|
||||
function addTranslation(item: Article) {
|
||||
openVersion(item.id)
|
||||
}
|
||||
|
||||
const menuHandlers: StoryMenuHandlers = {
|
||||
open: openVersion,
|
||||
addTranslation,
|
||||
publish: handlePublish,
|
||||
unpublish: handleUnpublish,
|
||||
copyUrl: handleCopyUrl,
|
||||
remove: handleDelete,
|
||||
}
|
||||
|
||||
function openNewArticleModal() {
|
||||
newArticleModalOpen.value = true
|
||||
}
|
||||
@@ -109,6 +178,20 @@ function clearSearch() {
|
||||
})
|
||||
}
|
||||
|
||||
function removeChip(key: FilterChipKey) {
|
||||
if (key === 'site') {
|
||||
list.changeSite('')
|
||||
} else if (key === 'lang') {
|
||||
list.changeLang('')
|
||||
} else if (key === 'status') {
|
||||
list.changeStatus('')
|
||||
} else if (key === 'author') {
|
||||
list.changeAuthor(0)
|
||||
} else {
|
||||
list.changeSeo(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onEmptyAction() {
|
||||
if (emptyVariant.value === 'needs-attention') {
|
||||
list.changeSeo(false)
|
||||
@@ -122,6 +205,10 @@ function onEmptyAction() {
|
||||
clearSearch()
|
||||
return
|
||||
}
|
||||
if (emptyVariant.value === 'no-matches') {
|
||||
list.clearFilters()
|
||||
return
|
||||
}
|
||||
if (emptyVariant.value === 'no-online') {
|
||||
list.changeStatus('draft')
|
||||
return
|
||||
@@ -136,28 +223,34 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<DashboardShell compact-padding>
|
||||
<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">{{ writerLine }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 md:gap-4">
|
||||
<div class="flex min-h-9.5 items-baseline gap-3">
|
||||
<h1
|
||||
data-testid="articles-heading"
|
||||
tabindex="-1"
|
||||
class="text-xl font-semibold text-highlighted"
|
||||
>
|
||||
{{ t('Articles') }}
|
||||
</h1>
|
||||
<p
|
||||
aria-live="polite"
|
||||
data-testid="unit-label"
|
||||
class="min-w-0 flex-1 truncate font-medium text-muted"
|
||||
>
|
||||
{{ unit }}
|
||||
</p>
|
||||
<UButton
|
||||
v-if="access.canCreate.value"
|
||||
icon="i-lucide-plus"
|
||||
class="hidden h-8 px-3 sm:flex"
|
||||
class="size-11 justify-center md:h-8 md:w-auto md:px-3"
|
||||
@click="openNewArticleModal"
|
||||
>
|
||||
{{ t('New article') }}
|
||||
<span class="max-md:sr-only">{{ t('New article') }}</span>
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<p v-if="writerLine" class="-mt-1 text-muted">{{ writerLine }}</p>
|
||||
|
||||
<ReadOnlyBanner
|
||||
v-if="access.readsOwnOnly.value"
|
||||
icon="i-lucide-eye-off"
|
||||
@@ -168,21 +261,32 @@ onMounted(() => {
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<ArticleListToolbar
|
||||
v-model:search="searchInput"
|
||||
:status="desired.status"
|
||||
:counts="counts"
|
||||
:needs-attention-count="needsAttentionCount"
|
||||
:needs-attention="desired.seo !== ''"
|
||||
:site="desired.site"
|
||||
:sites="sites"
|
||||
:several-sites="severalSites"
|
||||
@update:status="list.changeStatus"
|
||||
@update:needs-attention="list.changeSeo"
|
||||
@update:site="list.changeSite"
|
||||
<ReadOnlyBanner
|
||||
v-else-if="!access.canCreate.value"
|
||||
:title="t('Showing only what you can read')"
|
||||
:description="
|
||||
t(
|
||||
'Languages, counts and missing-translation results reflect the versions you have permission to see. You can\'t create translations on this site.',
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<ArticleFilterBar
|
||||
v-model:search="searchInput"
|
||||
:fields="fields"
|
||||
:chip-count="chips.length"
|
||||
@update:site="list.changeSite"
|
||||
@update:lang="list.changeLang"
|
||||
@update:status="list.changeStatus"
|
||||
@update:author="list.changeAuthor"
|
||||
@update:seo="list.changeSeo"
|
||||
@open-filters="filtersOpen = true"
|
||||
/>
|
||||
|
||||
<ArticleFilterChips :chips="chips" @remove="removeChip" @clear="list.clearFilters" />
|
||||
|
||||
<ArticleListTabs :tabs="tabs" :active="activeTab" @select="list.changeTab" />
|
||||
|
||||
<ArticleListNotice
|
||||
v-if="notice"
|
||||
:kind="notice.kind"
|
||||
@@ -191,7 +295,8 @@ onMounted(() => {
|
||||
@retry="list.retryNotice"
|
||||
/>
|
||||
|
||||
<template v-if="hasLoaded || loadFailed || showSkeleton">
|
||||
<ArticleListFailure v-if="loadFailed" @retry="list.load(desired, 'replace')" />
|
||||
<template v-else-if="hasLoaded || showSkeleton">
|
||||
<OwnScopeEmpty
|
||||
v-if="emptyVariant === 'no-drafts' && access.readsOwnOnly.value"
|
||||
icon="i-lucide-file-text"
|
||||
@@ -204,35 +309,46 @@ onMounted(() => {
|
||||
:action="access.canCreate.value ? t('New article') : undefined"
|
||||
@action="openNewArticleModal"
|
||||
/>
|
||||
<ArticleListEmpty
|
||||
v-else-if="
|
||||
emptyVariant === 'no-results' ||
|
||||
emptyVariant === 'nothing-missing' ||
|
||||
emptyVariant === 'no-matches'
|
||||
"
|
||||
:variant="emptyVariant"
|
||||
:query="shown.q"
|
||||
:lang="shown.lang"
|
||||
@action="onEmptyAction"
|
||||
/>
|
||||
<EmptyState
|
||||
v-else-if="emptyVariant"
|
||||
:variant="emptyVariant"
|
||||
:query="emptyVariant === 'no-site' ? siteName : shown.q"
|
||||
@action="onEmptyAction"
|
||||
/>
|
||||
<ArticleListSkeleton v-else-if="skeletonActive" />
|
||||
<template v-else>
|
||||
<ArticleTable
|
||||
:articles="items"
|
||||
:loading="skeletonActive"
|
||||
:skeleton-rows="skeletonRows"
|
||||
<StoryTable
|
||||
v-if="desktop"
|
||||
:items="items"
|
||||
:mode="mode"
|
||||
:sort="effectiveSort(shown.sort)"
|
||||
:sites="severalSites ? sites : []"
|
||||
:default-lang="defaultLang"
|
||||
:busy-ids="busyIds"
|
||||
:handlers="menuHandlers"
|
||||
:lang="shown.lang"
|
||||
@sort="list.changeSort"
|
||||
@publish="handlePublish"
|
||||
@unpublish="handleUnpublish"
|
||||
@delete="handleDelete"
|
||||
@copy-url="handleCopyUrl"
|
||||
@add-translation="addTranslation"
|
||||
/>
|
||||
<ArticleCardList
|
||||
:articles="items"
|
||||
:loading="skeletonActive"
|
||||
:skeleton-rows="skeletonRows"
|
||||
<StoryCardList
|
||||
v-else
|
||||
:items="items"
|
||||
:mode="mode"
|
||||
:default-lang="defaultLang"
|
||||
:busy-ids="busyIds"
|
||||
@publish="handlePublish"
|
||||
@unpublish="handleUnpublish"
|
||||
@delete="handleDelete"
|
||||
@copy-url="handleCopyUrl"
|
||||
:handlers="menuHandlers"
|
||||
:lang="shown.lang"
|
||||
@add-translation="addTranslation"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -249,14 +365,18 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
v-if="access.canCreate.value"
|
||||
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>
|
||||
<ArticleFiltersSheet
|
||||
v-model:open="filtersOpen"
|
||||
:fields="fields"
|
||||
:total="total"
|
||||
:chip-count="chips.length"
|
||||
@update:site="list.changeSite"
|
||||
@update:lang="list.changeLang"
|
||||
@update:status="list.changeStatus"
|
||||
@update:author="list.changeAuthor"
|
||||
@update:seo="list.changeSeo"
|
||||
@clear="list.clearFilters"
|
||||
/>
|
||||
|
||||
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
|
||||
</DashboardShell>
|
||||
|
||||
Reference in new issue
Block a user