Files
RenewCMS/web/src/views/ArticleView.vue
T

384 lines
11 KiB
Vue

<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import DashboardShell from '@/components/DashboardShell.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 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'
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(() => {
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,
usableSite: (query) => siteList.usableSite(query),
})
const {
items,
total,
counts,
shown,
desired,
hasLoaded,
loadFailed,
notice,
showSkeleton,
searchInput,
settled,
skeletonActive,
showPaginator,
pageCount,
pendingPage,
} = list
const siteList = useArticleSites(() => shown.value.site)
const { sites, severalSites, siteName } = siteList
const newArticleModalOpen = ref(false)
const filtersOpen = ref(false)
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
}
if (shown.value.status === 'online') {
return 'no-online' as const
}
if (shown.value.seo) {
return 'needs-attention' as const
}
if (shown.value.site) {
return 'no-site' as const
}
return 'no-articles' as const
})
const { busyIds, handlePublish, handleUnpublish, handleCopyUrl, handleDelete } =
useArticleRowActions({
items,
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
}
function onArticleCreated(article: Article) {
router.push(`/article/${article.id}/edit`)
}
function clearSearch() {
list.clearSearch()
nextTick(() => {
document.querySelector<HTMLInputElement>('input[type="search"]')?.focus()
})
}
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)
return
}
if (emptyVariant.value === 'no-site') {
list.changeSite('')
return
}
if (emptyVariant.value === 'no-results') {
clearSearch()
return
}
if (emptyVariant.value === 'no-matches') {
list.clearFilters()
return
}
if (emptyVariant.value === 'no-online') {
list.changeStatus('draft')
return
}
openNewArticleModal()
}
onMounted(() => {
list.start(siteList.loadSites())
})
</script>
<template>
<DashboardShell compact-padding>
<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="size-11 justify-center md:h-8 md:w-auto md:px-3"
@click="openNewArticleModal"
>
<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"
:title="t('Drafts by others are hidden')"
:description="
t(
'Your role reads online articles and the drafts you created. The counts follow the same rule.',
)
"
/>
<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"
:failed-page="notice.kind === 'error' ? notice.query.page : shown.page"
:shown-page="shown.page"
@retry="list.retryNotice"
/>
<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"
:title="t('You have no drafts')"
:description="
t(
'Drafts you create appear here. Your role can\'t see drafts by other people.',
)
"
: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>
<StoryTable
v-if="desktop"
:items="items"
:mode="mode"
:sort="effectiveSort(shown.sort)"
:default-lang="defaultLang"
:busy-ids="busyIds"
:handlers="menuHandlers"
:lang="shown.lang"
@sort="list.changeSort"
@add-translation="addTranslation"
/>
<StoryCardList
v-else
:items="items"
:mode="mode"
:default-lang="defaultLang"
:busy-ids="busyIds"
:handlers="menuHandlers"
:lang="shown.lang"
@add-translation="addTranslation"
/>
</template>
</template>
<AppPaginator
v-if="showPaginator"
:page="shown.page"
:page-count="pageCount"
:total="total"
:size="shown.size"
:loading-page="pendingPage"
@update:page="list.changePage"
@update:size="list.changeSize"
/>
</div>
<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>
</template>