384 lines
11 KiB
Vue
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>
|