feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally)
This commit is contained in:
1 parent
3d81d2c717
commit
6b8990c55e
239 files changed
+9194
-1354
No files matched your search
+95
-265
@@ -3,22 +3,21 @@ import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ArticleTable from '@/components/ArticleTable.vue'
|
||||
import ArticleCardList from '@/components/ArticleCardList.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import NewArticleModal from '@/components/NewArticleModal.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import AppPaginator from '@/components/AppPaginator.vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import ArticleSiteFilter from '@/components/ArticleSiteFilter.vue'
|
||||
import { useArticleRowActions } from '@/composables/useArticleRowActions'
|
||||
import {
|
||||
useArticles,
|
||||
type Article,
|
||||
type ArticleCounts,
|
||||
type ArticleStatus,
|
||||
} from '@/composables/useArticles'
|
||||
import { bodyImageCount } from '@/lib/bodyImageIds'
|
||||
import { useSites, type Site } from '@/composables/useSites'
|
||||
import {
|
||||
ARTICLE_PAGE_SIZES,
|
||||
effectiveSort,
|
||||
@@ -43,12 +42,11 @@ type Notice = { kind: 'range' } | { kind: 'error'; query: ArticleListQuery; mode
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { request } = useApi()
|
||||
const { listArticles } = useArticles()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
const { listSites } = useSites()
|
||||
|
||||
const items = ref<Article[]>([])
|
||||
const sites = ref<Site[]>([])
|
||||
const total = ref(0)
|
||||
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
|
||||
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
|
||||
@@ -62,13 +60,13 @@ function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
|
||||
|
||||
const shown = ref<ArticleListQuery>(parseWithStoredSize(route.query))
|
||||
const desired = ref<ArticleListQuery>(shown.value)
|
||||
const sitesSettled = ref(false)
|
||||
const hasLoaded = ref(false)
|
||||
const loading = ref(true)
|
||||
const loadFailed = ref(false)
|
||||
const pendingPage = ref<number | null>(null)
|
||||
const notice = ref<Notice | null>(null)
|
||||
const showSkeleton = ref(false)
|
||||
const busyIds = ref<Set<number>>(new Set())
|
||||
const newArticleModalOpen = ref(false)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -115,6 +113,11 @@ const tabItems = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const severalSites = computed(() => sites.value.length > 1)
|
||||
const siteName = computed(
|
||||
() => sites.value.find((site) => site.key === shown.value.site)?.name ?? shown.value.site,
|
||||
)
|
||||
|
||||
const settled = computed(() => hasLoaded.value && !loadFailed.value)
|
||||
const skeletonActive = computed(() => loading.value && showSkeleton.value)
|
||||
const skeletonRows = computed(() => items.value.length || DEFAULT_SKELETON_ROWS)
|
||||
@@ -132,6 +135,9 @@ const emptyVariant = computed(() => {
|
||||
if (shown.value.status === 'online') {
|
||||
return 'no-online' as const
|
||||
}
|
||||
if (shown.value.site) {
|
||||
return 'no-site' as const
|
||||
}
|
||||
return 'no-articles' as const
|
||||
})
|
||||
|
||||
@@ -191,10 +197,15 @@ async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notic
|
||||
response.total > 0 &&
|
||||
query.page > lastPage
|
||||
) {
|
||||
await load({ ...query, page: lastPage }, 'replace', { kind: 'range' })
|
||||
await load({ ...query, page: lastPage }, 'replace', {
|
||||
kind: 'range',
|
||||
})
|
||||
return
|
||||
}
|
||||
const effective = { ...query, page: response.total === 0 ? 1 : query.page }
|
||||
const effective = {
|
||||
...query,
|
||||
page: response.total === 0 ? 1 : query.page,
|
||||
}
|
||||
items.value = response.items
|
||||
total.value = response.total
|
||||
counts.value = response.counts
|
||||
@@ -240,8 +251,22 @@ function changeStatus(status: '' | ArticleStatus) {
|
||||
load({ ...desired.value, status, page: 1 }, 'push')
|
||||
}
|
||||
|
||||
function changeSite(site: string) {
|
||||
if (site === desired.value.site) {
|
||||
return
|
||||
}
|
||||
load({ ...desired.value, site, page: 1 }, 'push')
|
||||
}
|
||||
|
||||
function changeSort(field: ArticleSortField) {
|
||||
load({ ...desired.value, sort: nextSort(desired.value.sort, field), page: 1 }, 'push')
|
||||
load(
|
||||
{
|
||||
...desired.value,
|
||||
sort: nextSort(desired.value.sort, field),
|
||||
page: 1,
|
||||
},
|
||||
'push',
|
||||
)
|
||||
}
|
||||
|
||||
function changeSearch(value: string) {
|
||||
@@ -252,6 +277,19 @@ function changeSearch(value: string) {
|
||||
load({ ...desired.value, q, page: 1 }, 'replace')
|
||||
}
|
||||
|
||||
async function fetchSites() {
|
||||
try {
|
||||
sites.value = await listSites()
|
||||
} catch {
|
||||
sites.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function usableSite(query: ArticleListQuery): ArticleListQuery {
|
||||
const known = severalSites.value && sites.value.some((site) => site.key === query.site)
|
||||
return query.site && !known ? { ...query, site: '' } : query
|
||||
}
|
||||
|
||||
function retryNotice() {
|
||||
if (notice.value?.kind === 'error') {
|
||||
load(notice.value.query, notice.value.mode)
|
||||
@@ -266,7 +304,10 @@ watch(
|
||||
}
|
||||
const ownReplace = syncedTarget !== null && sameRouteQuery(route.query, syncedTarget)
|
||||
syncedTarget = null
|
||||
const next = ownReplace ? parseArticleQuery(route.query) : parseWithStoredSize(route.query)
|
||||
const parsed = ownReplace
|
||||
? parseArticleQuery(route.query)
|
||||
: parseWithStoredSize(route.query)
|
||||
const next = sitesSettled.value ? usableSite(parsed) : parsed
|
||||
if (hasLoaded.value && sameArticleQuery(next, shown.value)) {
|
||||
syncUrl(shown.value, 'replace')
|
||||
return
|
||||
@@ -279,249 +320,14 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
function setBusy(id: number, busy: boolean) {
|
||||
const next = new Set(busyIds.value)
|
||||
if (busy) {
|
||||
next.add(id)
|
||||
} else {
|
||||
next.delete(id)
|
||||
}
|
||||
busyIds.value = next
|
||||
}
|
||||
|
||||
function shiftCounts(article: Article, delta: number) {
|
||||
const key = article.is_online ? 'online' : 'draft'
|
||||
counts.value = {
|
||||
...counts.value,
|
||||
all: Math.max(0, counts.value.all + delta),
|
||||
[key]: Math.max(0, counts.value[key] + delta),
|
||||
}
|
||||
}
|
||||
|
||||
function detachRow(id: number) {
|
||||
if (!items.value.some((article) => article.id === id)) {
|
||||
return
|
||||
}
|
||||
items.value = items.value.filter((article) => article.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
if (items.value.length === 0 && total.value > 0) {
|
||||
const page = Math.min(shown.value.page, pageCountFor(total.value, shown.value.size))
|
||||
load({ ...shown.value, page }, 'replace')
|
||||
}
|
||||
}
|
||||
|
||||
function removeArticle(article: Article) {
|
||||
shiftCounts(article, -1)
|
||||
detachRow(article.id)
|
||||
}
|
||||
|
||||
function replaceArticle(updated: Article, previous: Article) {
|
||||
items.value = items.value.map((existing) => (existing.id === updated.id ? updated : existing))
|
||||
if (updated.is_online === previous.is_online) {
|
||||
return
|
||||
}
|
||||
counts.value = {
|
||||
...counts.value,
|
||||
online: counts.value.online + (updated.is_online ? 1 : -1),
|
||||
draft: counts.value.draft + (updated.is_online ? -1 : 1),
|
||||
}
|
||||
const filter = shown.value.status
|
||||
if (filter && (filter === 'online') !== updated.is_online) {
|
||||
detachRow(updated.id)
|
||||
}
|
||||
}
|
||||
|
||||
function notFoundToast(article: Article) {
|
||||
return {
|
||||
id: `article-not-found-${article.id}`,
|
||||
color: 'info' as const,
|
||||
icon: 'i-lucide-info',
|
||||
get title() {
|
||||
return t('That article no longer exists')
|
||||
},
|
||||
get description() {
|
||||
return t('It was deleted somewhere else, so we removed it from the list.')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function setOnline(article: Article, makeOnline: boolean) {
|
||||
setBusy(article.id, true)
|
||||
try {
|
||||
const updated = await request<Article>(
|
||||
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
|
||||
{ method: 'PATCH' },
|
||||
)
|
||||
replaceArticle(updated, article)
|
||||
toast.add(() => ({
|
||||
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return makeOnline ? t('Article published') : t('Article unpublished')
|
||||
},
|
||||
get description() {
|
||||
return makeOnline
|
||||
? t('\u0022{0}\u0022 is now online.', [article.title])
|
||||
: t('\u0022{0}\u0022 is now offline.', [article.title])
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
removeArticle(article)
|
||||
toast.add(() => notFoundToast(article))
|
||||
return
|
||||
}
|
||||
toast.add(() => ({
|
||||
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${article.id}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
get title() {
|
||||
return makeOnline ? t("Couldn't publish") : t("Couldn't unpublish")
|
||||
},
|
||||
get description() {
|
||||
return t("The server didn't respond. Nothing changed.")
|
||||
},
|
||||
duration: 0,
|
||||
actions: [
|
||||
{
|
||||
get label() {
|
||||
return t('Retry')
|
||||
},
|
||||
onClick: () => setOnline(article, makeOnline),
|
||||
},
|
||||
],
|
||||
}))
|
||||
} finally {
|
||||
setBusy(article.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
function findItem(id: number) {
|
||||
return items.value.find((existing) => existing.id === id)
|
||||
}
|
||||
|
||||
function handlePublish(id: number) {
|
||||
const article = findItem(id)
|
||||
if (article) {
|
||||
setOnline(article, true)
|
||||
}
|
||||
}
|
||||
|
||||
function handleUnpublish(id: number) {
|
||||
const article = findItem(id)
|
||||
if (article) {
|
||||
setOnline(article, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyUrl(id: number) {
|
||||
const article = findItem(id)
|
||||
if (!article) {
|
||||
return
|
||||
}
|
||||
const url = `${window.location.origin}/v1/article/${id}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.add(() => ({
|
||||
id: `article-link-copied-${id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Link copied')
|
||||
},
|
||||
get description() {
|
||||
return t('The API URL for \u0022{0}\u0022 is on your clipboard.', [article.title])
|
||||
},
|
||||
}))
|
||||
} catch {
|
||||
toast.add(() => ({
|
||||
id: `article-link-copy-failed-${id}`,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
get title() {
|
||||
return t("Couldn't copy the link")
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function deleteDescription(article: Article) {
|
||||
const count = bodyImageCount(article.body)
|
||||
const imagesPhrase = count === 0 ? '' : t(' Its images stay in your library.')
|
||||
return t("\u0022{0}\u0022 will be deleted.{1} This can't be undone.", [
|
||||
article.title,
|
||||
imagesPhrase,
|
||||
])
|
||||
}
|
||||
|
||||
async function performDelete(article: Article) {
|
||||
try {
|
||||
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
|
||||
removeArticle(article)
|
||||
toast.add(() => ({
|
||||
id: `article-deleted-${article.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Article deleted')
|
||||
},
|
||||
get description() {
|
||||
return t('\u0022{0}\u0022 is gone.', [article.title])
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
removeArticle(article)
|
||||
toast.add(() => notFoundToast(article))
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
t("Couldn't delete the article, so it's still there. Try again in a moment."),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) {
|
||||
await nextTick()
|
||||
const index = idsAtOpen.indexOf(deletedId)
|
||||
const nextId = idsAtOpen[index + 1] ?? idsAtOpen.slice(0, index).reverse()[0]
|
||||
if (nextId != null) {
|
||||
document.querySelector<HTMLElement>(`[data-row-title="${nextId}"]`)?.focus()
|
||||
return
|
||||
}
|
||||
document.querySelector<HTMLElement>('[data-testid="articles-heading"]')?.focus()
|
||||
}
|
||||
|
||||
async function handleDelete(article: Article) {
|
||||
const idsAtOpen = items.value.map((existing) => existing.id)
|
||||
const confirmed = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
get title() {
|
||||
return t('Delete this article?')
|
||||
},
|
||||
get description() {
|
||||
return deleteDescription(article)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Delete article')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Cancel')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Deleting…')
|
||||
},
|
||||
tone: 'danger',
|
||||
action: () => performDelete(article),
|
||||
},
|
||||
})
|
||||
.open()
|
||||
if (confirmed) {
|
||||
focusRowOrHeading(idsAtOpen, article.id)
|
||||
}
|
||||
}
|
||||
const { busyIds, handlePublish, handleUnpublish, handleCopyUrl, handleDelete } =
|
||||
useArticleRowActions({
|
||||
items,
|
||||
total,
|
||||
counts,
|
||||
shown,
|
||||
reload: load,
|
||||
})
|
||||
|
||||
function openNewArticleModal() {
|
||||
newArticleModalOpen.value = true
|
||||
@@ -541,6 +347,10 @@ function clearSearch() {
|
||||
}
|
||||
|
||||
function onEmptyAction() {
|
||||
if (emptyVariant.value === 'no-site') {
|
||||
changeSite('')
|
||||
return
|
||||
}
|
||||
if (emptyVariant.value === 'no-results') {
|
||||
clearSearch()
|
||||
return
|
||||
@@ -552,7 +362,18 @@ function onEmptyAction() {
|
||||
openNewArticleModal()
|
||||
}
|
||||
|
||||
onMounted(() => load(shown.value, 'replace'))
|
||||
onMounted(async () => {
|
||||
const siteRequest = fetchSites().finally(() => {
|
||||
sitesSettled.value = true
|
||||
})
|
||||
if (shown.value.site) {
|
||||
await siteRequest
|
||||
const usable = usableSite(shown.value)
|
||||
shown.value = usable
|
||||
desired.value = usable
|
||||
}
|
||||
load(shown.value, 'replace')
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
requestToken++
|
||||
@@ -595,14 +416,22 @@ onBeforeUnmount(() => {
|
||||
:content="false"
|
||||
:ui="{ list: 'gap-5', trigger: 'px-0' }"
|
||||
/>
|
||||
<UInput
|
||||
v-model="searchInput"
|
||||
type="search"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="t('Search by title')"
|
||||
class="w-full sm:w-65"
|
||||
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
|
||||
/>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
|
||||
<UInput
|
||||
v-model="searchInput"
|
||||
type="search"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="t('Search by title')"
|
||||
class="w-full sm:w-65"
|
||||
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
|
||||
/>
|
||||
<ArticleSiteFilter
|
||||
v-if="severalSites"
|
||||
:model-value="desired.site"
|
||||
:sites="sites"
|
||||
@update:model-value="changeSite"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
@@ -638,7 +467,7 @@ onBeforeUnmount(() => {
|
||||
<EmptyState
|
||||
v-if="emptyVariant"
|
||||
:variant="emptyVariant"
|
||||
:query="shown.q"
|
||||
:query="emptyVariant === 'no-site' ? siteName : shown.q"
|
||||
@action="onEmptyAction"
|
||||
/>
|
||||
<template v-else>
|
||||
@@ -647,6 +476,7 @@ onBeforeUnmount(() => {
|
||||
:loading="skeletonActive"
|
||||
:skeleton-rows="skeletonRows"
|
||||
:sort="effectiveSort(shown.sort)"
|
||||
:sites="severalSites ? sites : []"
|
||||
:busy-ids="busyIds"
|
||||
@sort="changeSort"
|
||||
@publish="handlePublish"
|
||||
|
||||
Reference in new issue
Block a user